summaryrefslogtreecommitdiffstats
path: root/browser/components/asrouter/tests
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/asrouter/tests')
-rw-r--r--browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs340
-rw-r--r--browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs199
-rw-r--r--browser/components/asrouter/tests/browser/browser.toml44
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js234
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js48
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_cfr.js932
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js505
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js188
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js158
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_infobar.js223
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js116
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_targeting.js1706
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js139
-rw-r--r--browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js149
-rw-r--r--browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js1122
-rw-r--r--browser/components/asrouter/tests/browser/browser_feature_callout_panel.js430
-rw-r--r--browser/components/asrouter/tests/browser/browser_trigger_listeners.js430
-rw-r--r--browser/components/asrouter/tests/browser/head.js66
-rw-r--r--browser/components/asrouter/tests/unit/ASRouter.test.js2870
-rw-r--r--browser/components/asrouter/tests/unit/ASRouterChild.test.js71
-rw-r--r--browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js153
-rw-r--r--browser/components/asrouter/tests/unit/ASRouterParent.test.js83
-rw-r--r--browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js428
-rw-r--r--browser/components/asrouter/tests/unit/ASRouterPreferences.test.js480
-rw-r--r--browser/components/asrouter/tests/unit/ASRouterTargeting.test.js574
-rw-r--r--browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js833
-rw-r--r--browser/components/asrouter/tests/unit/CFRMessageProvider.test.js32
-rw-r--r--browser/components/asrouter/tests/unit/CFRPageActions.test.js1414
-rw-r--r--browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js459
-rw-r--r--browser/components/asrouter/tests/unit/ModalOverlay.test.jsx69
-rw-r--r--browser/components/asrouter/tests/unit/MomentsPageHub.test.js336
-rw-r--r--browser/components/asrouter/tests/unit/RemoteL10n.test.js217
-rw-r--r--browser/components/asrouter/tests/unit/TargetingDocs.test.js88
-rw-r--r--browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js652
-rw-r--r--browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js762
-rw-r--r--browser/components/asrouter/tests/unit/asrouter-utils.test.js118
-rw-r--r--browser/components/asrouter/tests/unit/constants.js131
-rw-r--r--browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx262
-rw-r--r--browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx112
-rw-r--r--browser/components/asrouter/tests/unit/unit-entry.js727
-rw-r--r--browser/components/asrouter/tests/xpcshell/head.js98
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js94
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js172
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js73
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js32
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js41
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js41
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js229
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js84
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_reach_experiments.js97
-rw-r--r--browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js37
-rw-r--r--browser/components/asrouter/tests/xpcshell/xpcshell.toml24
52 files changed, 18922 insertions, 0 deletions
diff --git a/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs
new file mode 100644
index 0000000000..e92b210c12
--- /dev/null
+++ b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs
@@ -0,0 +1,340 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// This file is generated by:
+// https://github.com/mozilla/messaging-system-inflight-assets/tree/master/scripts/export-all.py
+
+export const InflightAssetsMessageProvider = {
+ getMessages() {
+ return [
+ {
+ id: "MILESTONE_MESSAGE",
+ groups: ["cfr"],
+ content: {
+ anchor_id: "tracking-protection-icon-box",
+ bucket_id: "CFR_MILESTONE_MESSAGE",
+ buttons: {
+ primary: {
+ action: {
+ type: "OPEN_PROTECTION_REPORT",
+ },
+ event: "PROTECTION",
+ label: {
+ string_id: "cfr-doorhanger-milestone-ok-button",
+ },
+ },
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-milestone-close-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ event: "DISMISS",
+ },
+ ],
+ },
+ category: "cfrFeatures",
+ heading_text: {
+ string_id: "cfr-doorhanger-milestone-heading",
+ },
+ layout: "short_message",
+ notification_text: "",
+ skip_address_bar_notifier: true,
+ text: "",
+ },
+ frequency: {
+ lifetime: 7,
+ },
+ targeting:
+ "pageLoad >= 4 && firefoxVersion < 87 && userPrefs.cfrFeatures",
+ template: "milestone_message",
+ trigger: {
+ id: "contentBlocking",
+ params: ["ContentBlockingMilestone"],
+ },
+ },
+ {
+ id: "MILESTONE_MESSAGE_87",
+ groups: ["cfr"],
+ content: {
+ anchor_id: "tracking-protection-icon-box",
+ bucket_id: "CFR_MILESTONE_MESSAGE",
+ buttons: {
+ primary: {
+ action: {
+ type: "OPEN_PROTECTION_REPORT",
+ },
+ event: "PROTECTION",
+ label: {
+ string_id: "cfr-doorhanger-milestone-ok-button",
+ },
+ },
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-milestone-close-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ event: "DISMISS",
+ },
+ ],
+ },
+ category: "cfrFeatures",
+ heading_text: {
+ string_id: "cfr-doorhanger-milestone-heading2",
+ },
+ layout: "short_message",
+ notification_text: "",
+ skip_address_bar_notifier: true,
+ text: "",
+ },
+ frequency: {
+ lifetime: 7,
+ },
+ targeting:
+ "pageLoad >= 4 && firefoxVersion >= 87 && userPrefs.cfrFeatures",
+ template: "milestone_message",
+ trigger: {
+ id: "contentBlocking",
+ params: ["ContentBlockingMilestone"],
+ },
+ },
+ {
+ id: "DOH_ROLLOUT_CONFIRMATION_89",
+ groups: ["cfr"],
+ targeting:
+ "profileAgeCreated < 1572480000000 && ( 'doh-rollout.enabled'|preferenceValue || 'doh-rollout.self-enabled'|preferenceValue || 'doh-rollout.ru.enabled'|preferenceValue || 'doh-rollout.ua.enabled'|preferenceValue ) && !( 'doh-rollout.disable-heuristics'|preferenceValue || 'doh-rollout.skipHeuristicsCheck'|preferenceValue || 'doh-rollout.doorhanger-decision'|preferenceValue ) && firefoxVersion >= 89",
+ template: "infobar",
+ content: {
+ priority: 3,
+ type: "global",
+ text: {
+ string_id: "cfr-doorhanger-doh-body",
+ },
+ buttons: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-doh-primary-button-2",
+ },
+ action: {
+ type: "ACCEPT_DOH",
+ },
+ primary: true,
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-doh-secondary-button",
+ },
+ action: {
+ type: "DISABLE_DOH",
+ },
+ },
+ {
+ label: {
+ string_id: "notification-learnmore-default-label",
+ },
+ supportPage: "dns-over-https",
+ callback: null,
+ action: {
+ type: "CANCEL",
+ },
+ },
+ ],
+ bucket_id: "DOH_ROLLOUT_CONFIRMATION_89",
+ category: "cfrFeatures",
+ },
+ frequency: {
+ lifetime: 3,
+ },
+ trigger: {
+ id: "openURL",
+ patterns: ["*://*/*"],
+ },
+ },
+ {
+ id: "INFOBAR_DEFAULT_AND_PIN_87",
+ groups: ["cfr"],
+ content: {
+ category: "cfrFeatures",
+ bucket_id: "INFOBAR_DEFAULT_AND_PIN_87",
+ text: {
+ string_id: "default-browser-notification-message",
+ },
+ type: "global",
+ buttons: [
+ {
+ label: {
+ string_id: "default-browser-notification-button",
+ },
+ action: {
+ type: "PIN_AND_DEFAULT",
+ },
+ primary: true,
+ accessKey: "P",
+ },
+ ],
+ },
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ template: "infobar",
+ frequency: {
+ lifetime: 2,
+ custom: [
+ {
+ period: 3024000000,
+ cap: 1,
+ },
+ ],
+ },
+ targeting:
+ "((firefoxVersion >= 87 && firefoxVersion < 89) || (firefoxVersion >= 89 && source == 'startup')) && !isDefaultBrowser && !'browser.shell.checkDefaultBrowser'|preferenceValue && isMajorUpgrade != true && platformName != 'linux' && ((currentDate|date - profileAgeCreated) / 604800000) >= 5 && !activeNotifications && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && ((currentDate|date - profileAgeCreated) / 604800000) < 15",
+ },
+ {
+ id: "CFR_FULL_VIDEO_SUPPORT_EN",
+ groups: ["cfr"],
+ targeting:
+ "firefoxVersion < 88 && firefoxVersion != 78 && localeLanguageCode in ['en', 'fr', 'de', 'ru', 'zh', 'es', 'it', 'pl']",
+ template: "cfr_doorhanger",
+ content: {
+ skip_address_bar_notifier: true,
+ persistent_doorhanger: true,
+ anchor_id: "PanelUI-menu-button",
+ layout: "icon_and_message",
+ text: {
+ string_id: "cfr-doorhanger-video-support-body",
+ },
+ buttons: {
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-cancel-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ ],
+ primary: {
+ label: {
+ string_id: "cfr-doorhanger-video-support-primary-button",
+ },
+ action: {
+ type: "OPEN_URL",
+ data: {
+ args: "https://support.mozilla.org/kb/update-firefox-latest-release",
+ where: "tabshifted",
+ },
+ },
+ },
+ },
+ bucket_id: "CFR_FULL_VIDEO_SUPPORT_EN",
+ heading_text: {
+ string_id: "cfr-doorhanger-video-support-header",
+ },
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ notification_text: "Message from Firefox",
+ category: "cfrFeatures",
+ },
+ frequency: {
+ lifetime: 3,
+ },
+ trigger: {
+ id: "openURL",
+ patterns: ["https://*/Amazon-Video/*", "https://*/Prime-Video/*"],
+ params: [
+ "www.hulu.com",
+ "hulu.com",
+ "www.netflix.com",
+ "netflix.com",
+ "www.disneyplus.com",
+ "disneyplus.com",
+ "www.hbomax.com",
+ "hbomax.com",
+ "www.sho.com",
+ "sho.com",
+ "www.directv.com",
+ "directv.com",
+ "www.starzplay.com",
+ "starzplay.com",
+ "www.sling.com",
+ "sling.com",
+ "www.facebook.com",
+ "facebook.com",
+ ],
+ },
+ },
+ {
+ id: "WNP_MOMENTS_12",
+ groups: ["moments-pages"],
+ content: {
+ action: {
+ data: {
+ expire: 1640908800000,
+ url: "https://www.mozilla.org/firefox/welcome/12",
+ },
+ id: "moments-wnp",
+ },
+ bucket_id: "WNP_MOMENTS_12",
+ },
+ targeting:
+ 'localeLanguageCode == "en" && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 1 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue',
+ template: "update_action",
+ trigger: {
+ id: "momentsUpdate",
+ },
+ },
+ {
+ id: "WNP_MOMENTS_13",
+ groups: ["moments-pages"],
+ content: {
+ action: {
+ data: {
+ expire: 1640908800000,
+ url: "https://www.mozilla.org/firefox/welcome/13",
+ },
+ id: "moments-wnp",
+ },
+ bucket_id: "WNP_MOMENTS_13",
+ },
+ targeting:
+ '(localeLanguageCode in ["en", "de", "fr", "nl", "it", "ms"] || locale == "es-ES") && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 0 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue',
+ template: "update_action",
+ trigger: {
+ id: "momentsUpdate",
+ },
+ },
+ {
+ id: "WNP_MOMENTS_14",
+ groups: ["moments-pages"],
+ content: {
+ action: {
+ data: {
+ expire: 1668470400000,
+ url: "https://www.mozilla.org/firefox/welcome/14",
+ },
+ id: "moments-wnp",
+ },
+ bucket_id: "WNP_MOMENTS_14",
+ },
+ targeting:
+ 'localeLanguageCode in ["en", "de", "fr"] && region in ["AT", "BE", "CA", "CH", "DE", "ES", "FI", "FR", "GB", "IE", "IT", "MY", "NL", "NZ", "SE", "SG", "US"] && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue',
+ template: "update_action",
+ trigger: {
+ id: "momentsUpdate",
+ },
+ },
+ ];
+ },
+};
diff --git a/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs
new file mode 100644
index 0000000000..5bfbec9557
--- /dev/null
+++ b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs
@@ -0,0 +1,199 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file is generated by browser/components/asrouter/bin/import-rollouts.js
+ * Run the following from the repository root to regenerate it:
+ * ./mach npm run import-rollouts --prefix=browser/components/asrouter
+ */
+
+export const NimbusRolloutMessageProvider = {
+ getMessages() {
+ return [
+ {
+ // Nimbus slug: fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout:treatment-a
+ // Version range: 116+
+ // Recipe: https://experimenter.services.mozilla.com/nimbus/fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout/summary#treatment-a
+ id: "fox-doodle-set-to-default-early-day-user-de-fr-it:A",
+ groups: ["eco"],
+ content: {
+ id: "fox-doodle-set-to-default-early-day-user-de-fr-it:A",
+ screens: [
+ {
+ id: "SET_DEFAULT",
+ content: {
+ logo: {
+ height: "140px",
+ imageURL:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png",
+ reducedMotionImageURL:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png",
+ },
+ title: {
+ raw: {
+ $l10n: {
+ id: "fox-doodle-trackers-title",
+ text: "Keep pesky trackers off your tail",
+ comment:
+ "This title is displayed together with the picture of a running fox with a long tail. In English, this is a figure of speech meaning 'stop something from following you'. If the localization of this message is challenging, consider using a simplified alternative as a reference for translation: 'Keep unwanted trackers away'.",
+ },
+ },
+ fontSize: "22px",
+ fontWeight: 590,
+ paddingBlock: "4px 0",
+ letterSpacing: 0,
+ paddingInline: "24px",
+ },
+ subtitle: {
+ raw: {
+ $l10n: {
+ id: "fox-doodle-trackers-subtitle",
+ text: "Say goodbye to annoying ad trackers and settle into a safer, speedy internet experience.",
+ comment: "",
+ },
+ },
+ fontSize: "15px",
+ lineHeight: "1.4",
+ marginBlock: "8px 16px",
+ letterSpacing: 0,
+ paddingInline: "24px",
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ primary_button: {
+ label: {
+ raw: {
+ $l10n: {
+ id: "fox-doodle-set-default-driving-primary-button-label",
+ text: "Open my links with Firefox",
+ comment: "",
+ },
+ },
+ marginBlock: "4px 0",
+ paddingBlock: "0",
+ paddingInline: "16px",
+ },
+ action: {
+ type: "SET_DEFAULT_BROWSER",
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ raw: {
+ $l10n: {
+ id: "fox-doodle-driving-secondary-button-label",
+ text: "Not now",
+ comment: "",
+ },
+ },
+ marginBlock: "0 -20px",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ backdrop: "transparent",
+ template: "multistage",
+ transitions: true,
+ },
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ priority: 1,
+ template: "spotlight",
+ frequency: {
+ lifetime: 1,
+ },
+ targeting:
+ "source == 'startup' && !willShowDefaultPrompt && !isMajorUpgrade && !activeNotifications && (((currentDate|date) - (profileAgeCreated|date)) / 3600000 >= 6) && !isDefaultBrowser",
+ },
+ {
+ // Nimbus slug: fox-doodle-set-to-default-early-day-user-en-treatment-a-rollout:treatment-a
+ // Version range: 116+
+ // Recipe: https://experimenter.services.mozilla.com/nimbus/fox-doodle-set-to-default-early-day-user-en-treatment-a-rollout/summary#treatment-a
+ id: "fox-doodle-set-to-default-early-day-user:A",
+ groups: ["eco"],
+ content: {
+ id: "fox-doodle-set-to-default-early-day-user:A",
+ screens: [
+ {
+ id: "SET_DEFAULT",
+ content: {
+ logo: {
+ height: "140px",
+ imageURL:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png",
+ reducedMotionImageURL:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png",
+ },
+ title: {
+ raw: "Keep pesky trackers off your tail",
+ fontSize: "22px",
+ fontWeight: 590,
+ paddingBlock: "4px 0",
+ letterSpacing: 0,
+ paddingInline: "24px",
+ },
+ subtitle: {
+ raw: "Say goodbye to annoying ad trackers and settle into a safer, speedy internet experience.",
+ fontSize: "15px",
+ lineHeight: "1.4",
+ marginBlock: "8px 16px",
+ letterSpacing: 0,
+ paddingInline: "24px",
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ primary_button: {
+ label: {
+ raw: "Open my links with Firefox",
+ marginBlock: "4px 0",
+ paddingBlock: "0",
+ paddingInline: "16px",
+ },
+ action: {
+ type: "SET_DEFAULT_BROWSER",
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ raw: "Not now",
+ marginBlock: "0 -20px",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ backdrop: "transparent",
+ template: "multistage",
+ transitions: true,
+ },
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ priority: 1,
+ template: "spotlight",
+ frequency: {
+ lifetime: 1,
+ },
+ targeting:
+ "source == 'startup' && !willShowDefaultPrompt && !isMajorUpgrade && !activeNotifications && (((currentDate|date) - (profileAgeCreated|date)) / 3600000 >= 6) && !isDefaultBrowser",
+ },
+ ];
+ },
+};
diff --git a/browser/components/asrouter/tests/browser/browser.toml b/browser/components/asrouter/tests/browser/browser.toml
new file mode 100644
index 0000000000..7bed40373d
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser.toml
@@ -0,0 +1,44 @@
+[DEFAULT]
+support-files = [
+ "../../../newtab/test/browser/blue_page.html",
+ "head.js",
+]
+
+["browser_asrouter_bug1761522.js"]
+
+["browser_asrouter_bug1800087.js"]
+
+["browser_asrouter_cfr.js"]
+https_first_disabled = true
+
+["browser_asrouter_experimentsAPILoader.js"]
+
+["browser_asrouter_group_frequency.js"]
+https_first_disabled = true
+
+["browser_asrouter_group_userprefs.js"]
+skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1643036
+
+["browser_asrouter_infobar.js"]
+
+["browser_asrouter_momentspagehub.js"]
+tags = "remote-settings"
+
+["browser_asrouter_targeting.js"]
+
+["browser_asrouter_toast_notification.js"]
+
+["browser_asrouter_toolbarbadge.js"]
+tags = "remote-settings"
+skip-if = ["a11y_checks"] # Bug 1854515 and 1858041 to investigate intermittent a11y_checks results (fails on Autoland, passes on Try)
+
+["browser_feature_callout_in_chrome.js"]
+skip-if = [
+ "os == 'mac' && debug", # Bug 1804349
+ "win11_2009", # Bug 1804349
+]
+
+["browser_feature_callout_panel.js"]
+
+["browser_trigger_listeners.js"]
+https_first_disabled = true
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js
new file mode 100644
index 0000000000..19fcb63131
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js
@@ -0,0 +1,234 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ASRouter, MessageLoaderUtils } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/PanelTestProvider.sys.mjs"
+);
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+const { RemoteL10n } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/RemoteL10n.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+// This pref is used to override the Remote Settings server URL in tests.
+// See SERVER_URL in services/settings/Utils.jsm for more details.
+const RS_SERVER_PREF = "services.settings.server";
+
+const FLUENT_CONTENT = "asrouter-test-string = Test Test Test\n";
+
+async function serveRemoteSettings() {
+ const server = new HttpServer();
+ server.start(-1);
+
+ const baseURL = `http://localhost:${server.identity.primaryPort}/`;
+ const attachmentUuid = crypto.randomUUID();
+ const attachment = new TextEncoder().encode(FLUENT_CONTENT);
+
+ // Serve an index so RS knows where to fetch images from.
+ server.registerPathHandler("/v1/", (request, response) => {
+ response.write(
+ JSON.stringify({
+ capabilities: {
+ attachments: {
+ base_url: `${baseURL}cdn`,
+ },
+ },
+ })
+ );
+ });
+
+ // Serve the ms-language-packs record for cfr-v1-ja-JP-mac, pointing to an attachment.
+ server.registerPathHandler(
+ "/v1/buckets/main/collections/ms-language-packs/records/cfr-v1-ja-JP-mac",
+ (request, response) => {
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader(
+ "Content-type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(
+ JSON.stringify({
+ permissions: {},
+ data: {
+ attachment: {
+ hash: "f9aead2693c4ff95c2764df72b43fdf5b3490ed06414588843848f991136040b",
+ size: attachment.buffer.byteLength,
+ filename: "asrouter.ftl",
+ location: `main-workspace/ms-language-packs/${attachmentUuid}`,
+ },
+ id: "cfr-v1-ja-JP-mac",
+ last_modified: Date.now(),
+ },
+ })
+ );
+ }
+ );
+
+ // Serve the attachment for ms-language-packs/cfr-va-ja-JP-mac.
+ server.registerPathHandler(
+ `/cdn/main-workspace/ms-language-packs/${attachmentUuid}`,
+ (request, response) => {
+ const stream = Cc[
+ "@mozilla.org/io/arraybuffer-input-stream;1"
+ ].createInstance(Ci.nsIArrayBufferInputStream);
+ stream.setData(attachment.buffer, 0, attachment.buffer.byteLength);
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-type", "application/octet-stream");
+ response.bodyOutputStream.writeFrom(stream, attachment.buffer.byteLength);
+ }
+ );
+
+ // Serve the list of changed collections. cfr must have changed, otherwise we
+ // won't attempt to fetch the cfr records (and then won't fetch
+ // ms-language-packs).
+ server.registerPathHandler(
+ "/v1/buckets/monitor/collections/changes/changeset",
+ (request, response) => {
+ const now = Date.now();
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader(
+ "Content-type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(
+ JSON.stringify({
+ timestamp: now,
+ changes: [
+ {
+ host: `localhost:${server.identity.primaryPort}`,
+ last_modified: now,
+ bucket: "main",
+ collection: "cfr",
+ },
+ ],
+ metadata: {},
+ })
+ );
+ }
+ );
+
+ const message = await PanelTestProvider.getMessages().then(msgs =>
+ msgs.find(msg => msg.id === "PERSONALIZED_CFR_MESSAGE")
+ );
+
+ // Serve the "changed" cfr entries. If there are no changes, then ASRouter
+ // won't re-fetch ms-language-packs.
+ server.registerPathHandler(
+ "/v1/buckets/main/collections/cfr/changeset",
+ (request, response) => {
+ const now = Date.now();
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader(
+ "Content-type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(
+ JSON.stringify({
+ timestamp: now,
+ changes: [message],
+ metadata: {},
+ })
+ );
+ }
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[RS_SERVER_PREF, `${baseURL}v1`]],
+ });
+
+ return async () => {
+ await new Promise(resolve => server.stop(() => resolve()));
+ await SpecialPowers.popPrefEnv();
+ };
+}
+
+add_task(async function test_asrouter() {
+ const MS_LANGUAGE_PACKS_DIR = PathUtils.join(
+ PathUtils.localProfileDir,
+ "settings",
+ "main",
+ "ms-language-packs"
+ );
+ const sandbox = sinon.createSandbox();
+ const stop = await serveRemoteSettings();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ JSON.stringify({
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ updateCyleInMs: 3600000,
+ }),
+ ],
+ ],
+ });
+ const localeService = Services.locale;
+ RemoteSettings("cfr").verifySignature = false;
+
+ registerCleanupFunction(async () => {
+ RemoteSettings("cfr").verifySignature = true;
+ Services.locale = localeService;
+ await SpecialPowers.popPrefEnv();
+ await stop();
+ sandbox.restore();
+ await IOUtils.remove(MS_LANGUAGE_PACKS_DIR, { recursive: true });
+ RemoteL10n.reloadL10n();
+ });
+
+ // We can't stub Services.locale.appLocaleAsBCP47 directly because its an
+ // XPCOM_Native object.
+ const fakeLocaleService = new Proxy(localeService, {
+ get(obj, prop) {
+ if (prop === "appLocaleAsBCP47") {
+ return "ja-JP-macos";
+ }
+ return obj[prop];
+ },
+ });
+
+ const localeSpy = sandbox.spy(MessageLoaderUtils, "locale", ["get"]);
+ Services.locale = fakeLocaleService;
+
+ const cfrProvider = ASRouter.state.providers.find(p => p.id === "cfr");
+ await ASRouter.loadMessagesFromAllProviders([cfrProvider]);
+
+ Assert.equal(
+ Services.locale.appLocaleAsBCP47,
+ "ja-JP-macos",
+ "Locale service returns ja-JP-macos"
+ );
+ Assert.ok(localeSpy.get.called, "MessageLoaderUtils.locale getter called");
+ Assert.ok(
+ localeSpy.get.alwaysReturned("ja-JP-mac"),
+ "MessageLoaderUtils.locale getter returned expected locale ja-JP-mac"
+ );
+
+ const path = PathUtils.join(
+ MS_LANGUAGE_PACKS_DIR,
+ "browser",
+ "newtab",
+ "asrouter.ftl"
+ );
+ Assert.ok(await IOUtils.exists(path), "asrouter.ftl was downloaded");
+ Assert.equal(
+ await IOUtils.readUTF8(path),
+ FLUENT_CONTENT,
+ "asrouter.ftl content matches expected"
+ );
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js b/browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js
new file mode 100644
index 0000000000..ce4e673742
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// TODO (Bug 1800937): Remove this whole test along with the migration code
+// after the next watershed release.
+
+const { ASRouterNewTabHook } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouterNewTabHook.sys.mjs"
+);
+const { ASRouterDefaultConfig } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs"
+);
+
+add_setup(() => ASRouterNewTabHook.destroy());
+
+// Test that the old pref format is migrated correctly to the new format.
+// provider.bucket -> provider.collection
+add_task(async function test_newtab_asrouter() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ JSON.stringify({
+ id: "cfr",
+ enabled: true,
+ type: "local",
+ bucket: "cfr", // The pre-migration property name is bucket.
+ updateCyleInMs: 3600000,
+ }),
+ ],
+ ],
+ });
+
+ await ASRouterNewTabHook.createInstance(ASRouterDefaultConfig());
+ const hook = await ASRouterNewTabHook.getInstance();
+ const router = hook._router;
+ if (!router.initialized) {
+ await router.waitForInitialized;
+ }
+
+ // Test that the pref's bucket is migrated to collection.
+ let cfrProvider = router.state.providers.find(p => p.id === "cfr");
+ Assert.equal(cfrProvider.collection, "cfr", "The collection name is correct");
+ Assert.ok(!cfrProvider.bucket, "The bucket name is removed");
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js
new file mode 100644
index 0000000000..0cab79994e
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js
@@ -0,0 +1,932 @@
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+const { ASRouterTriggerListeners } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs"
+);
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+
+const { TelemetryFeed } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/TelemetryFeed.sys.mjs"
+);
+
+const createDummyRecommendation = ({
+ action,
+ category,
+ heading_text,
+ layout,
+ skip_address_bar_notifier,
+ show_in_private_browsing,
+ template,
+}) => {
+ let recommendation = {
+ template,
+ groups: ["mochitest-group"],
+ content: {
+ layout: layout || "addon_recommendation",
+ category,
+ anchor_id: "page-action-buttons",
+ skip_address_bar_notifier,
+ show_in_private_browsing,
+ heading_text: heading_text || "Mochitest",
+ info_icon: {
+ label: { attributes: { tooltiptext: "Why am I seeing this" } },
+ sumo_path: "extensionrecommendations",
+ },
+ icon: "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg",
+ icon_dark_theme:
+ "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg",
+ learn_more: "extensionrecommendations",
+ addon: {
+ id: "addon-id",
+ title: "Addon name",
+ icon: "chrome://browser/skin/addons/addon-install-downloading.svg",
+ author: "Author name",
+ amo_url: "https://example.com",
+ },
+ descriptionDetails: { steps: [] },
+ text: "Mochitest",
+ buttons: {
+ primary: {
+ label: {
+ value: "OK",
+ attributes: { accesskey: "O" },
+ },
+ action: {
+ type: action.type,
+ data: {},
+ },
+ },
+ secondary: [
+ {
+ label: {
+ value: "Cancel",
+ attributes: { accesskey: "C" },
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: {
+ value: "Cancel 1",
+ attributes: { accesskey: "A" },
+ },
+ },
+ {
+ label: {
+ value: "Cancel 2",
+ attributes: { accesskey: "B" },
+ },
+ },
+ ],
+ },
+ },
+ };
+ recommendation.content.notification_text = new String("Mochitest"); // eslint-disable-line
+ recommendation.content.notification_text.attributes = {
+ tooltiptext: "Mochitest tooltip",
+ "a11y-announcement": "Mochitest announcement",
+ };
+ return recommendation;
+};
+
+function checkCFRAddonsElements(notification) {
+ Assert.ok(notification.hidden === false, "Panel should be visible");
+ Assert.equal(
+ notification.getAttribute("data-notification-category"),
+ "addon_recommendation",
+ "Panel have correct data attribute"
+ );
+ Assert.ok(
+ notification.querySelector("#cfr-notification-footer-text-and-addon-info"),
+ "Panel should have addon info container"
+ );
+ Assert.ok(
+ notification.querySelector("#cfr-notification-footer-filled-stars"),
+ "Panel should have addon rating info"
+ );
+ Assert.ok(
+ notification.querySelector("#cfr-notification-author"),
+ "Panel should have author info"
+ );
+}
+
+function checkCFRTrackingProtectionMilestone(notification) {
+ Assert.ok(notification.hidden === false, "Panel should be visible");
+ Assert.ok(
+ notification.getAttribute("data-notification-category") === "short_message",
+ "Panel have correct data attribute"
+ );
+}
+
+function clearNotifications() {
+ for (let notification of PopupNotifications._currentNotifications) {
+ notification.remove();
+ }
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+}
+
+function trigger_cfr_panel(
+ browser,
+ trigger,
+ {
+ action = { type: "CANCEL" },
+ heading_text,
+ category = "cfrAddons",
+ layout,
+ skip_address_bar_notifier = false,
+ use_single_secondary_button = false,
+ show_in_private_browsing = false,
+ template = "cfr_doorhanger",
+ } = {}
+) {
+ // a fake action type will result in the action being ignored
+ const recommendation = createDummyRecommendation({
+ action,
+ category,
+ heading_text,
+ layout,
+ skip_address_bar_notifier,
+ show_in_private_browsing,
+ template,
+ });
+ if (category !== "cfrAddons") {
+ delete recommendation.content.addon;
+ }
+ if (use_single_secondary_button) {
+ recommendation.content.buttons.secondary = [
+ recommendation.content.buttons.secondary[0],
+ ];
+ }
+
+ clearNotifications();
+ return CFRPageActions.addRecommendation(
+ browser,
+ trigger,
+ recommendation,
+ // Use the real AS dispatch method to trigger real notifications
+ ASRouter.dispatchCFRAction
+ );
+}
+
+add_setup(async function () {
+ // Store it in order to restore to the original value
+ const { _fetchLatestAddonVersion } = CFRPageActions;
+ // Prevent fetching the real addon url and making a network request
+ CFRPageActions._fetchLatestAddonVersion = x => "http://example.com";
+
+ registerCleanupFunction(() => {
+ CFRPageActions._fetchLatestAddonVersion = _fetchLatestAddonVersion;
+ clearNotifications();
+ CFRPageActions.clearRecommendations();
+ });
+});
+
+add_task(async function test_cfr_notification_show() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "browser.newtabpage.activity-stream.telemetry"
+ );
+ // Reset fog to clear pings here for private window test later.
+ Services.fog.testResetFOG();
+ });
+
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.telemetry",
+ true
+ );
+
+ Services.fog.testResetFOG();
+ let pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(Glean.messagingSystem.source.testGetValue(), "CFR");
+ });
+
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ const oldFocus = document.activeElement;
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+ Assert.equal(
+ document.activeElement,
+ oldFocus,
+ "Focus didn't move when panel was shown"
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .button
+ );
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+
+ Assert.ok(pingSubmitted, "Recorded an event");
+});
+
+add_task(async function test_cfr_notification_show() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ let response = await trigger_cfr_panel(browser, "example.com", {
+ heading_text: "First Message",
+ });
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ // Try adding another message
+ response = await trigger_cfr_panel(browser, "example.com", {
+ heading_text: "Second Message",
+ });
+ Assert.equal(
+ response,
+ false,
+ "Should return false if second call did not add the message"
+ );
+
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+
+ Assert.equal(
+ document.getElementById("cfr-notification-header-label").value,
+ "First Message",
+ "The first message should be visible"
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .button
+ );
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+});
+
+add_task(async function test_cfr_notification_minimize() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ let response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.hasAttribute("cfr-recommendation-state"),
+ "Wait for the notification to show up and have a state"
+ );
+ Assert.ok(
+ gURLBar.getAttribute("cfr-recommendation-state") === "expanded",
+ "CFR recomendation state is correct"
+ );
+
+ gURLBar.focus();
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.getAttribute("cfr-recommendation-state") === "collapsed",
+ "After urlbar focus the CFR notification should collapse"
+ );
+
+ // Open the panel and click to dismiss to ensure cleanup
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+});
+
+add_task(async function test_cfr_notification_minimize_2() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ let response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.hasAttribute("cfr-recommendation-state"),
+ "Wait for the notification to show up and have a state"
+ );
+ Assert.ok(
+ gURLBar.getAttribute("cfr-recommendation-state") === "expanded",
+ "CFR recomendation state is correct"
+ );
+
+ // Open the panel and click to dismiss to ensure cleanup
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .secondaryButton,
+ "There should be a cancel button"
+ );
+
+ // Click the Not Now button
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .secondaryButton.click();
+
+ await hidePanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification"),
+ "The notification should not dissapear"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.getAttribute("cfr-recommendation-state") === "collapsed",
+ "Clicking the secondary button should collapse the notification"
+ );
+
+ clearNotifications();
+ CFRPageActions.clearRecommendations();
+});
+
+add_task(async function test_cfr_addon_install() {
+ // addRecommendation checks that scheme starts with http and host matches
+ const browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const response = await trigger_cfr_panel(browser, "example.com", {
+ action: { type: "INSTALL_ADDON_FROM_URL" },
+ });
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+ checkCFRAddonsElements(
+ document.getElementById("contextual-feature-recommendation-notification")
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .button
+ );
+ const hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+
+ let [notification] = PopupNotifications.panel.childNodes;
+ // Trying to install the addon will trigger a progress popup or an error popup if
+ // running the test multiple times in a row
+ Assert.ok(
+ notification.id === "addon-progress-notification" ||
+ notification.id === "addon-install-failed-notification",
+ "Should try to install the addon"
+ );
+
+ clearNotifications();
+});
+
+add_task(
+ async function test_cfr_tracking_protection_milestone_notification_remove() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.contentblocking.cfr-milestone.milestone-achieved", 1000],
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ `{"id":"cfr","enabled":true,"type":"local","localProvider":"CFRMessageProvider","updateCycleInMs":3600000}`,
+ ],
+ ],
+ });
+
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ event: "ContentBlockingMilestone",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+
+ await showPanel;
+
+ const notification = document.getElementById(
+ "contextual-feature-recommendation-notification"
+ );
+
+ checkCFRTrackingProtectionMilestone(notification);
+
+ Assert.ok(notification.secondaryButton);
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ notification.secondaryButton.click();
+ await hidePanel;
+ await SpecialPowers.popPrefEnv();
+ clearNotifications();
+ }
+);
+
+add_task(async function test_cfr_addon_and_features_show() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ // Trigger Feature CFR
+ let response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ let showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ const notification = document.getElementById(
+ "contextual-feature-recommendation-notification"
+ );
+ checkCFRAddonsElements(notification);
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(notification.button);
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+
+ // Trigger Addon CFR
+ response = await trigger_cfr_panel(browser, "example.com", {
+ action: { type: "PIN_CURRENT_TAB" },
+ category: "cfrAddons",
+ });
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+ checkCFRAddonsElements(
+ document.getElementById("contextual-feature-recommendation-notification")
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(notification.button);
+ hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+});
+
+add_task(async function test_onLocationChange_cb() {
+ let count = 0;
+ const triggerHandler = () => ++count;
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/blue_page.html";
+ const browser = gBrowser.selectedBrowser;
+
+ await ASRouterTriggerListeners.get("openURL").init(triggerHandler, [
+ "example.com",
+ ]);
+
+ BrowserTestUtils.startLoadingURIString(browser, "about:blank");
+ await BrowserTestUtils.browserLoaded(browser, false, "about:blank");
+
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ Assert.equal(count, 1, "Count navigation to example.com");
+
+ // Anchor scroll triggers a location change event with the same document
+ // https://searchfox.org/mozilla-central/rev/8848b9741fc4ee4e9bc3ae83ea0fc048da39979f/uriloader/base/nsIWebProgressListener.idl#400-403
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/#foo");
+ await BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ "http://example.com/#foo"
+ );
+
+ Assert.equal(count, 1, "It should ignore same page navigation");
+
+ BrowserTestUtils.startLoadingURIString(browser, TEST_URL);
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_URL);
+
+ Assert.equal(count, 2, "We moved to a new document");
+
+ registerCleanupFunction(() => {
+ ASRouterTriggerListeners.get("openURL").uninit();
+ });
+});
+
+add_task(async function test_matchPattern() {
+ let count = 0;
+ const triggerHandler = () => ++count;
+ const frequentVisitsTrigger = ASRouterTriggerListeners.get("frequentVisits");
+ await frequentVisitsTrigger.init(triggerHandler, [], ["*://*.example.com/"]);
+
+ const browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "Registered pattern matched the current location"
+ );
+
+ BrowserTestUtils.startLoadingURIString(browser, "about:config");
+ await BrowserTestUtils.browserLoaded(browser, false, "about:config");
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "Navigated to a new page but not a match"
+ );
+
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "Navigated to a location that matches the pattern but within 15 mins"
+ );
+
+ BrowserTestUtils.startLoadingURIString(browser, "http://www.example.com/");
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "http://www.example.com/"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("www.example.com").length === 1,
+ "www.example.com is a different host that also matches the pattern."
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "www.example.com is a different host that also matches the pattern."
+ );
+
+ registerCleanupFunction(() => {
+ ASRouterTriggerListeners.get("frequentVisits").uninit();
+ });
+});
+
+add_task(async function test_providerNames() {
+ const providersBranch =
+ "browser.newtabpage.activity-stream.asrouter.providers.";
+ const cfrProviderPrefs = Services.prefs.getChildList(providersBranch);
+ for (const prefName of cfrProviderPrefs) {
+ const prefValue = JSON.parse(Services.prefs.getStringPref(prefName));
+ if (prefValue && prefValue.id) {
+ Assert.equal(
+ prefValue.id,
+ prefName.slice(providersBranch.length),
+ "Provider id and pref name do not match"
+ );
+ }
+ }
+});
+
+add_task(async function test_cfr_notification_keyboard() {
+ // addRecommendation checks that scheme starts with http and host matches
+ const browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ // Open the panel with the keyboard.
+ // Toolbar buttons aren't always focusable; toolbar keyboard navigation
+ // makes them focusable on demand. Therefore, we must force focus.
+ const button = document.getElementById("contextual-feature-recommendation");
+ button.setAttribute("tabindex", "-1");
+ button.focus();
+ button.removeAttribute("tabindex");
+
+ let focused = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "focus",
+ true
+ );
+ EventUtils.synthesizeKey(" ");
+ await focused;
+ Assert.ok(true, "Focus inside panel after button pressed");
+
+ let hidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape");
+ await hidden;
+ Assert.ok(true, "Panel hidden after Escape pressed");
+
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Need to dismiss the notification to clear the RecommendationMap
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ const hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+});
+
+add_task(function test_updateCycleForProviders() {
+ Services.prefs
+ .getChildList("browser.newtabpage.activity-stream.asrouter.providers.")
+ .forEach(provider => {
+ const prefValue = JSON.parse(Services.prefs.getStringPref(provider, ""));
+ if (prefValue && prefValue.type === "remote-settings") {
+ Assert.ok(prefValue.updateCycleInMs);
+ }
+ });
+});
+
+add_task(async function test_heartbeat_tactic_2() {
+ clearNotifications();
+ registerCleanupFunction(() => {
+ // Remove the tab opened by clicking the heartbeat message
+ gBrowser.removeCurrentTab();
+ clearNotifications();
+ });
+
+ const msg = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "HEARTBEAT_TACTIC_2"
+ );
+ const shown = await CFRPageActions.addRecommendation(
+ gBrowser.selectedBrowser,
+ null,
+ {
+ ...msg,
+ id: `HEARTBEAT_MOCHITEST_${Date.now()}`,
+ groups: ["mochitest-group"],
+ targeting: true,
+ },
+ // Use the real AS dispatch method to trigger real notifications
+ ASRouter.dispatchCFRAction
+ );
+
+ Assert.ok(shown, "Heartbeat CFR added");
+
+ // Wait for visibility change
+ BrowserTestUtils.waitForCondition(
+ () => document.getElementById("contextual-feature-recommendation"),
+ "Heartbeat button exists"
+ );
+
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ Services.urlFormatter.formatURL(msg.content.action.url),
+ true
+ );
+
+ document.getElementById("contextual-feature-recommendation").click();
+
+ await newTabPromise;
+});
+
+add_task(async function test_cfr_doorhanger_in_private_window() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "browser.newtabpage.activity-stream.telemetry"
+ );
+ });
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.telemetry",
+ true
+ );
+
+ let pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(Glean.messagingSystem.source.testGetValue(), "CFR");
+ Assert.equal(
+ Glean.messagingSystem.messageId.testGetValue(),
+ "n/a",
+ "Omitted message_id consistent with CFR telemetry policy"
+ );
+ Assert.equal(
+ Glean.messagingSystem.clientId.testGetValue(),
+ undefined,
+ "Omitted client_id consistent with CFR telemetry policy"
+ );
+ });
+
+ const win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://example.com/"
+ );
+ const browser = tab.linkedBrowser;
+
+ const response1 = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ !response1,
+ "CFR should not be shown in a private window if show_in_private_browsing is false"
+ );
+
+ const response2 = await trigger_cfr_panel(browser, "example.com", {
+ show_in_private_browsing: true,
+ });
+ Assert.ok(
+ response2,
+ "CFR should be shown in a private window if show_in_private_browsing is true"
+ );
+
+ const shownPromise = BrowserTestUtils.waitForEvent(
+ win.PopupNotifications.panel,
+ "popupshown"
+ );
+ win.document.getElementById("contextual-feature-recommendation").click();
+ await shownPromise;
+
+ const hiddenPromise = BrowserTestUtils.waitForEvent(
+ win.PopupNotifications.panel,
+ "popuphidden"
+ );
+ const button = win.document.getElementById(
+ "contextual-feature-recommendation-notification"
+ )?.button;
+ Assert.ok(button, "CFR doorhanger button found");
+ button.click();
+ await hiddenPromise;
+
+ Assert.ok(pingSubmitted, "Submitted a CFR messaging system ping");
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js b/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js
new file mode 100644
index 0000000000..14f4dda54f
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js
@@ -0,0 +1,505 @@
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
+);
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentFakes, ExperimentTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+const { TelemetryFeed } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/TelemetryFeed.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const MESSAGE_CONTENT = {
+ id: "xman_test_message",
+ groups: [],
+ content: {
+ text: "This is a test CFR",
+ addon: {
+ id: "954390",
+ icon: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png",
+ title: "Facebook Container",
+ users: "1455872",
+ author: "Mozilla",
+ rating: "4.5",
+ amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/",
+ },
+ buttons: {
+ primary: {
+ label: {
+ string_id: "cfr-doorhanger-extension-ok-button",
+ },
+ action: {
+ data: {
+ url: "about:blank",
+ },
+ type: "INSTALL_ADDON_FROM_URL",
+ },
+ },
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-cancel-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ data: {
+ origin: "CFR",
+ category: "general-cfraddons",
+ },
+ type: "OPEN_PREFERENCES_PAGE",
+ },
+ },
+ ],
+ },
+ category: "cfrAddons",
+ layout: "short_message",
+ bucket_id: "CFR_M1",
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ heading_text: "Welcome to the experiment",
+ notification_text: {
+ string_id: "cfr-doorhanger-extension-notification2",
+ },
+ },
+ trigger: {
+ id: "openURL",
+ params: [
+ "www.facebook.com",
+ "facebook.com",
+ "www.instagram.com",
+ "instagram.com",
+ "www.whatsapp.com",
+ "whatsapp.com",
+ "web.whatsapp.com",
+ "www.messenger.com",
+ "messenger.com",
+ ],
+ },
+ template: "cfr_doorhanger",
+ frequency: {
+ lifetime: 3,
+ },
+ targeting: "true",
+};
+
+const getExperiment = async feature => {
+ let recipe = ExperimentFakes.recipe(
+ // In tests by default studies/experiments are turned off. We turn them on
+ // to run the test and rollback at the end. Cleanup causes unenrollment so
+ // for cases where the test runs multiple times we need unique ids.
+ `test_xman_${feature}_${Date.now()}`,
+ {
+ id: "xman_test_message",
+ bucketConfig: {
+ count: 100,
+ start: 0,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ }
+ );
+ recipe.branches[0].features[0].featureId = feature;
+ recipe.branches[0].features[0].value = MESSAGE_CONTENT;
+ recipe.branches[1].features[0].featureId = feature;
+ recipe.branches[1].features[0].value = MESSAGE_CONTENT;
+ recipe.featureIds = [feature];
+ await ExperimentTestUtils.validateExperiment(recipe);
+ return recipe;
+};
+
+const getCFRExperiment = async () => {
+ return getExperiment("cfr");
+};
+
+const getLegacyCFRExperiment = async () => {
+ let recipe = ExperimentFakes.recipe(`test_xman_cfr_${Date.now()}`, {
+ id: "xman_test_message",
+ bucketConfig: {
+ count: 100,
+ start: 0,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ });
+
+ delete recipe.branches[0].features;
+ delete recipe.branches[1].features;
+ recipe.branches[0].feature = {
+ featureId: "cfr",
+ value: MESSAGE_CONTENT,
+ };
+ recipe.branches[1].feature = {
+ featureId: "cfr",
+ value: MESSAGE_CONTENT,
+ };
+ return recipe;
+};
+
+const client = RemoteSettings("nimbus-desktop-experiments");
+
+// no `add_task` because we want to run this setup before each test not before
+// the entire test suite.
+async function setup(experiment) {
+ // Store the experiment in RS local db to bypass synchronization.
+ await client.db.importChanges({}, Date.now(), [experiment], { clear: true });
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["app.shield.optoutstudies.enabled", true],
+ ["datareporting.healthreport.uploadEnabled", true],
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments",
+ `{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+}
+
+async function cleanup() {
+ await client.db.clear();
+ await SpecialPowers.popPrefEnv();
+ // Reload the provider
+ await ASRouter._updateMessageProviders();
+}
+
+/**
+ * Assert that a message is (or optionally is not) present in the ASRouter
+ * messages list, optionally waiting for it to be present/not present.
+ * @param {string} id message id
+ * @param {boolean} [found=true] expect the message to be found
+ * @param {boolean} [wait=true] check for the message until found/not found
+ * @returns {Promise<Message|null>} resolves with the message, if found
+ */
+async function assertMessageInState(id, found = true, wait = true) {
+ if (wait) {
+ await BrowserTestUtils.waitForCondition(
+ () => !!ASRouter.state.messages.find(m => m.id === id) === found,
+ `Message ${id} should ${found ? "" : "not"} be found in ASRouter state`
+ );
+ }
+ const message = ASRouter.state.messages.find(m => m.id === id);
+ Assert.equal(
+ !!message,
+ found,
+ `Message ${id} should ${found ? "" : "not"} be found`
+ );
+ return message || null;
+}
+
+add_task(async function test_loading_experimentsAPI() {
+ const experiment = await getCFRExperiment();
+ await setup(experiment);
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ const telemetryFeedInstance = new TelemetryFeed();
+ Assert.ok(
+ telemetryFeedInstance.isInCFRCohort,
+ "Telemetry should return true"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_loading_fxms_message_1_feature() {
+ const experiment = await getExperiment("fxms-message-1");
+ await setup(experiment);
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "fxms-message-1" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_loading_experimentsAPI_legacy() {
+ const experiment = await getLegacyCFRExperiment();
+ await setup(experiment);
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ const telemetryFeedInstance = new TelemetryFeed();
+ Assert.ok(
+ telemetryFeedInstance.isInCFRCohort,
+ "Telemetry should return true"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_loading_experimentsAPI_rollout() {
+ const rollout = await getCFRExperiment();
+ rollout.isRollout = true;
+ rollout.branches.pop();
+
+ await setup(rollout);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(() =>
+ ExperimentAPI.getRolloutMetaData({ featureId: "cfr" })
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_exposure_ping() {
+ // Reset this check to allow sending multiple exposure pings in tests
+ NimbusFeatures.cfr._didSendExposureEvent = false;
+ const experiment = await getCFRExperiment();
+ await setup(experiment);
+ Services.telemetry.clearScalars();
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ const exposureSpy = sinon.spy(ExperimentAPI, "recordExposureEvent");
+
+ await ASRouter.sendTriggerMessage({
+ tabId: 1,
+ browser: gBrowser.selectedBrowser,
+ id: "openURL",
+ param: { host: "messenger.com" },
+ });
+
+ Assert.ok(exposureSpy.callCount === 1, "Should send exposure ping");
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "telemetry.event_counts",
+ "normandy#expose#nimbus_experiment",
+ 1
+ );
+
+ exposureSpy.restore();
+ await cleanup();
+});
+
+add_task(async function test_exposure_ping_legacy() {
+ // Reset this check to allow sending multiple exposure pings in tests
+ NimbusFeatures.cfr._didSendExposureEvent = false;
+ const experiment = await getLegacyCFRExperiment();
+ await setup(experiment);
+ Services.telemetry.clearScalars();
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ const exposureSpy = sinon.spy(ExperimentAPI, "recordExposureEvent");
+
+ await ASRouter.sendTriggerMessage({
+ tabId: 1,
+ browser: gBrowser.selectedBrowser,
+ id: "openURL",
+ param: { host: "messenger.com" },
+ });
+
+ Assert.ok(exposureSpy.callCount === 1, "Should send exposure ping");
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "telemetry.event_counts",
+ "normandy#expose#nimbus_experiment",
+ 1
+ );
+
+ exposureSpy.restore();
+ await cleanup();
+});
+
+add_task(async function test_forceEnrollUpdatesMessages() {
+ const experiment = await getCFRExperiment();
+
+ await setup(experiment);
+ await SpecialPowers.pushPrefEnv({
+ set: [["nimbus.debug", true]],
+ });
+
+ await assertMessageInState("xman_test_message", false, false);
+
+ await RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: experiment.slug,
+ branch: experiment.branches[0].slug,
+ });
+
+ await assertMessageInState("xman_test_message");
+
+ await ExperimentManager.unenroll(`optin-${experiment.slug}`, "cleanup");
+ await SpecialPowers.popPrefEnv();
+ await cleanup();
+});
+
+add_task(async function test_update_on_enrollments_changed() {
+ // Check that the message is not already present
+ await assertMessageInState("xman_test_message", false, false);
+
+ const experiment = await getCFRExperiment();
+ let enrollmentChanged = TestUtils.topicObserved("nimbus:enrollments-updated");
+ await setup(experiment);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+ await enrollmentChanged;
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_emptyMessage() {
+ const experiment = ExperimentFakes.recipe(`empty_${Date.now()}`, {
+ id: "empty",
+ branches: [
+ {
+ slug: "a",
+ ratio: 1,
+ features: [
+ {
+ featureId: "cfr",
+ value: {},
+ },
+ ],
+ },
+ ],
+ bucketConfig: {
+ start: 0,
+ count: 100,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ });
+
+ await setup(experiment);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await ASRouter._updateMessageProviders();
+
+ const experimentsProvider = ASRouter.state.providers.find(
+ p => p.id === "messaging-experiments"
+ );
+
+ // Clear all messages
+ ASRouter.setState(state => ({
+ messages: [],
+ }));
+
+ await ASRouter.loadMessagesFromAllProviders([experimentsProvider]);
+
+ Assert.deepEqual(
+ ASRouter.state.messages,
+ [],
+ "ASRouter should have loaded zero messages"
+ );
+
+ await cleanup();
+});
+
+add_task(async function test_multiMessageTreatment() {
+ const featureId = "cfr";
+ // Add an array of two messages to the first branch
+ const messages = [
+ { ...MESSAGE_CONTENT, id: "multi-message-1" },
+ { ...MESSAGE_CONTENT, id: "multi-message-2" },
+ ];
+ const recipe = ExperimentFakes.recipe(`multi-message_${Date.now()}`, {
+ id: `multi-message`,
+ bucketConfig: {
+ count: 100,
+ start: 0,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [{ featureId, value: { template: "multi", messages } }],
+ },
+ ],
+ });
+ await ExperimentTestUtils.validateExperiment(recipe);
+
+ await setup(recipe);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ messages
+ .map(m => ASRouter.state.messages.find(n => n.id === m.id))
+ .every(Boolean),
+ "Experiment message found in ASRouter state"
+ );
+ Assert.ok(true, "Experiment message found in ASRouter state");
+
+ await cleanup();
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js b/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js
new file mode 100644
index 0000000000..58f47ae6bf
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js
@@ -0,0 +1,188 @@
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+
+/**
+ * Load and modify a message for the test.
+ */
+add_setup(async function () {
+ const initialMsgCount = ASRouter.state.messages.length;
+ const heartbeatMsg = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "HEARTBEAT_TACTIC_2"
+ );
+ const testMessage = {
+ ...heartbeatMsg,
+ groups: ["messaging-experiments"],
+ targeting: "true",
+ // Ensure no overlap due to frequency capping with other tests
+ id: `HEARTBEAT_MESSAGE_${Date.now()}`,
+ };
+ const client = RemoteSettings("cfr");
+ await client.db.importChanges({}, Date.now(), [testMessage], {
+ clear: true,
+ });
+
+ // Force the CFR provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > initialMsgCount,
+ "Should load the extra heartbeat message"
+ );
+
+ BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.find(m => m.id === testMessage.id),
+ "Wait to load the message"
+ );
+
+ const msg = ASRouter.state.messages.find(m => m.id === testMessage.id);
+ Assert.equal(msg.targeting, "true");
+ Assert.equal(msg.groups[0], "messaging-experiments");
+
+ registerCleanupFunction(async () => {
+ await client.db.clear();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length === initialMsgCount,
+ "Should reset messages"
+ );
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+/**
+ * Test group frequency capping.
+ * Message has a lifetime frequency of 3 but it's group has a lifetime frequency
+ * of 2. It should only show up twice.
+ * We update the provider to remove any daily limitations so it should show up
+ * on every new tab load.
+ */
+add_task(async function test_heartbeat_tactic_2() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_URL = "http://example.com";
+ const msg = ASRouter.state.messages.find(m =>
+ m.groups.includes("messaging-experiments")
+ );
+ Assert.ok(msg, "Message found");
+ const groupConfiguration = {
+ id: "messaging-experiments",
+ enabled: true,
+ frequency: { lifetime: 2 },
+ };
+ const client = RemoteSettings("message-groups");
+ await client.db.importChanges({}, Date.now(), [groupConfiguration], {
+ clear: true,
+ });
+
+ // Force the WNPanel provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.message-groups",
+ `{"id":"message-groups","enabled":true,"type":"remote-settings","collection":"message-groups","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ const msgs = await client.get();
+ return msgs.find(m => m.id === groupConfiguration.id);
+ }, "Wait for RS message");
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadAllMessageGroups();
+
+ let groupState = await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.groups.find(g => g.id === groupConfiguration.id),
+ "Wait for group config to load"
+ );
+ Assert.ok(groupState, "Group config found");
+ Assert.ok(groupState.enabled, "Group is enabled");
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.startLoadingURIString(tab1.linkedBrowser, TEST_URL);
+
+ let chiclet = document.getElementById("contextual-feature-recommendation");
+ Assert.ok(chiclet, "CFR chiclet element found (tab1)");
+ await BrowserTestUtils.waitForCondition(
+ () => !chiclet.hidden,
+ "Chiclet should be visible (tab1)"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ ASRouter.state.messageImpressions[msg.id] &&
+ ASRouter.state.messageImpressions[msg.id].length === 1,
+ "First impression recorded"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.startLoadingURIString(tab2.linkedBrowser, TEST_URL);
+
+ Assert.ok(chiclet, "CFR chiclet element found (tab2)");
+ await BrowserTestUtils.waitForCondition(
+ () => !chiclet.hidden,
+ "Chiclet should be visible (tab2)"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ ASRouter.state.messageImpressions[msg.id] &&
+ ASRouter.state.messageImpressions[msg.id].length === 2,
+ "Second impression recorded"
+ );
+
+ Assert.ok(
+ !ASRouter.isBelowFrequencyCaps(msg),
+ "Should have reached freq limit"
+ );
+
+ BrowserTestUtils.removeTab(tab2);
+
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.startLoadingURIString(tab3.linkedBrowser, TEST_URL);
+
+ await BrowserTestUtils.waitForCondition(
+ () => chiclet.hidden,
+ "Heartbeat button should be hidden"
+ );
+ Assert.equal(
+ ASRouter.state.messageImpressions[msg.id] &&
+ ASRouter.state.messageImpressions[msg.id].length,
+ 2,
+ "Number of impressions did not increase"
+ );
+
+ BrowserTestUtils.removeTab(tab3);
+
+ info("Cleanup");
+ await client.db.clear();
+ // Reset group impressions
+ await ASRouter.resetGroupsState();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await SpecialPowers.popPrefEnv();
+ CFRPageActions.clearRecommendations();
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js b/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js
new file mode 100644
index 0000000000..3bfc05ba48
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js
@@ -0,0 +1,158 @@
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+
+/**
+ * Load and modify a message for the test.
+ */
+add_setup(async function () {
+ const initialMsgCount = ASRouter.state.messages.length;
+ const heartbeatMsg = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "HEARTBEAT_TACTIC_2"
+ );
+ const testMessage = {
+ ...heartbeatMsg,
+ groups: ["messaging-experiments"],
+ targeting: "true",
+ // Ensure no overlap due to frequency capping with other tests
+ id: `HEARTBEAT_MESSAGE_${Date.now()}`,
+ };
+ const client = RemoteSettings("cfr");
+ await client.db.importChanges({}, Date.now(), [testMessage], { clear: true });
+
+ // Force the CFR provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > initialMsgCount,
+ "Should load the extra heartbeat message"
+ );
+
+ BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.find(m => m.id === testMessage.id),
+ "Wait to load the message"
+ );
+
+ const msg = ASRouter.state.messages.find(m => m.id === testMessage.id);
+ Assert.equal(msg.targeting, "true");
+ Assert.equal(msg.groups[0], "messaging-experiments");
+
+ registerCleanupFunction(async () => {
+ await client.db.clear();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length === initialMsgCount,
+ "Should reset messages"
+ );
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+/**
+ * Test group user preferences.
+ * Group is enabled if both user preferences are enabled.
+ */
+add_task(async function test_heartbeat_tactic_2() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_URL = "http://example.com";
+ const msg = ASRouter.state.messages.find(m =>
+ m.groups.includes("messaging-experiments")
+ );
+ Assert.ok(msg, "Message found");
+ const groupConfiguration = {
+ id: "messaging-experiments",
+ enabled: true,
+ userPreferences: ["browser.userPreference.messaging-experiments"],
+ };
+ const client = RemoteSettings("message-groups");
+ await client.db.importChanges({}, Date.now(), [groupConfiguration], {
+ clear: true,
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.message-groups",
+ `{"id":"message-groups","enabled":true,"type":"remote-settings","collection":"message-groups","updateCycleInMs":0}`,
+ ],
+ ["browser.userPreference.messaging-experiments", true],
+ ],
+ });
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ const msgs = await client.get();
+ return msgs.find(m => m.id === groupConfiguration.id);
+ }, "Wait for RS message");
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadAllMessageGroups();
+
+ let groupState = await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.groups.find(g => g.id === groupConfiguration.id),
+ "Wait for group config to load"
+ );
+ Assert.ok(groupState, "Group config found");
+ Assert.ok(groupState.enabled, "Group is enabled");
+ Assert.ok(ASRouter.isUnblockedMessage(msg), "Message is unblocked");
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.startLoadingURIString(tab1.linkedBrowser, TEST_URL);
+
+ let chiclet = document.getElementById("contextual-feature-recommendation");
+ Assert.ok(chiclet, "CFR chiclet element found");
+ await BrowserTestUtils.waitForCondition(
+ () => !chiclet.hidden,
+ "Chiclet should be visible (userprefs enabled)"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.userPreference.messaging-experiments", false]],
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ ASRouter.state.groups.find(
+ g => g.id === groupConfiguration.id && !g.enable
+ ),
+ "Wait for group config to load"
+ );
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.startLoadingURIString(tab2.linkedBrowser, TEST_URL);
+
+ await BrowserTestUtils.waitForCondition(
+ () => chiclet.hidden,
+ "Heartbeat button should not be visible (userprefs disabled)"
+ );
+
+ info("Cleanup");
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ await client.db.clear();
+ // Reset group impressions
+ await ASRouter.resetGroupsState();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await SpecialPowers.popPrefEnv();
+ CFRPageActions.clearRecommendations();
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js
new file mode 100644
index 0000000000..b80b3ec7a4
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js
@@ -0,0 +1,223 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { InfoBar } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/InfoBar.sys.mjs"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+add_task(async function show_and_send_telemetry() {
+ let message = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ );
+
+ Assert.ok(message.id, "Found the message");
+
+ let dispatchStub = sinon.stub();
+ let infobar = await InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ {
+ ...message,
+ content: {
+ priority: window.gNotificationBox.PRIORITY_WARNING_HIGH,
+ ...message.content,
+ },
+ },
+ dispatchStub
+ );
+
+ Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION");
+ // This is the call to increment impressions for frequency capping
+ Assert.equal(dispatchStub.firstCall.args[0].type, "IMPRESSION");
+ Assert.equal(dispatchStub.firstCall.args[0].data.id, message.id);
+ // This is the telemetry ping
+ Assert.equal(dispatchStub.secondCall.args[0].data.event, "IMPRESSION");
+ Assert.equal(dispatchStub.secondCall.args[0].data.message_id, message.id);
+ Assert.equal(
+ infobar.notification.priority,
+ window.gNotificationBox.PRIORITY_WARNING_HIGH,
+ "Has the priority level set in the message definition"
+ );
+
+ let primaryBtn = infobar.notification.buttonContainer.querySelector(
+ ".notification-button.primary"
+ );
+
+ Assert.ok(primaryBtn, "Has a primary button");
+ primaryBtn.click();
+
+ Assert.equal(dispatchStub.callCount, 4, "Called again with CLICK + removed");
+ Assert.equal(dispatchStub.thirdCall.args[0].type, "USER_ACTION");
+ Assert.equal(
+ dispatchStub.lastCall.args[0].data.event,
+ "CLICK_PRIMARY_BUTTON"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => !InfoBar._activeInfobar,
+ "Wait for notification to be dismissed by primary btn click."
+ );
+});
+
+add_task(async function react_to_trigger() {
+ let message = {
+ ...(await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ ),
+ };
+ message.targeting = "true";
+ message.content.type = "tab";
+ message.groups = [];
+ message.provider = ASRouter.state.providers[0].id;
+ message.content.message = "Infobar Mochitest";
+ await ASRouter.setState({ messages: [message] });
+
+ let notificationStack = gBrowser.getNotificationBox(gBrowser.selectedBrowser);
+ Assert.ok(
+ !notificationStack.currentNotification,
+ "No notification to start with"
+ );
+
+ await ASRouter.sendTriggerMessage({
+ browser: BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ id: "defaultBrowserCheck",
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => notificationStack.currentNotification,
+ "Wait for notification to show"
+ );
+
+ Assert.equal(
+ notificationStack.currentNotification.getAttribute("value"),
+ message.id,
+ "Notification id should match"
+ );
+
+ let defaultPriority = notificationStack.PRIORITY_SYSTEM;
+ Assert.ok(
+ notificationStack.currentNotification.priority === defaultPriority,
+ "Notification has default priority"
+ );
+ // Dismiss the notification
+ notificationStack.currentNotification.closeButtonEl.click();
+});
+
+add_task(async function dismiss_telemetry() {
+ let message = {
+ ...(await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ ),
+ };
+ message.content.type = "tab";
+
+ let dispatchStub = sinon.stub();
+ let infobar = await InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ // Remove any IMPRESSION pings
+ dispatchStub.reset();
+
+ infobar.notification.closeButtonEl.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () => infobar.notification === null,
+ "Set to null by `removed` event"
+ );
+
+ Assert.equal(dispatchStub.callCount, 1, "Only called once");
+ Assert.equal(
+ dispatchStub.firstCall.args[0].data.event,
+ "DISMISSED",
+ "Called with dismissed"
+ );
+
+ // Remove DISMISSED ping
+ dispatchStub.reset();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ infobar = await InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => dispatchStub.callCount > 0,
+ "Wait for impression ping"
+ );
+
+ // Remove IMPRESSION ping
+ dispatchStub.reset();
+ BrowserTestUtils.removeTab(tab);
+
+ await BrowserTestUtils.waitForCondition(
+ () => infobar.notification === null,
+ "Set to null by `disconnect` event"
+ );
+
+ // Called by closing the tab and triggering "disconnect"
+ Assert.equal(dispatchStub.callCount, 1, "Only called once");
+ Assert.equal(
+ dispatchStub.firstCall.args[0].data.event,
+ "DISMISSED",
+ "Called with dismissed"
+ );
+});
+
+add_task(async function prevent_multiple_messages() {
+ let message = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ );
+
+ Assert.ok(message.id, "Found the message");
+
+ let dispatchStub = sinon.stub();
+ let infobar = await InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION");
+
+ // Try to stack 2 notifications
+ await InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ Assert.equal(dispatchStub.callCount, 2, "Impression count did not increase");
+
+ // Dismiss the first notification
+ infobar.notification.closeButtonEl.click();
+ Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification");
+
+ // Reset impressions count
+ dispatchStub.reset();
+ // Try show the message again
+ infobar = await InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+ Assert.ok(InfoBar._activeInfobar, "activeInfobar is set");
+ Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION");
+ // Dismiss the notification again
+ infobar.notification.closeButtonEl.click();
+ Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification");
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js
new file mode 100644
index 0000000000..f752d01116
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js
@@ -0,0 +1,116 @@
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/PanelTestProvider.sys.mjs"
+);
+const { MomentsPageHub } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/MomentsPageHub.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once";
+
+add_task(async function test_with_rs_messages() {
+ // Force the WNPanel provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel",
+ `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+ const [msg] = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ const initialMessageCount = ASRouter.state.messages.length;
+ const client = RemoteSettings("cfr");
+ await client.db.importChanges(
+ {},
+ Date.now(),
+ [
+ {
+ // Modify targeting and randomize message name to work around the message
+ // getting blocked (for --verify)
+ ...msg,
+ id: `MOMENTS_MOCHITEST_${Date.now()}`,
+ targeting: "true",
+ },
+ ],
+ { clear: true }
+ );
+ // Reload the provider
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ // Wait to load the WNPanel messages
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > initialMessageCount,
+ "Messages did not load"
+ );
+
+ await MomentsPageHub.messageRequest({
+ triggerId: "momentsUpdate",
+ template: "update_action",
+ });
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "").length;
+ }, "Pref value was not set");
+
+ let value = Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "");
+ is(JSON.parse(value).url, msg.content.action.data.url, "Correct value set");
+
+ // Insert a new message and test that priority override works as expected
+ msg.content.action.data.url = "https://www.mozilla.org/#mochitest";
+ await client.db.create(
+ // Modify targeting to ensure the messages always show up
+ {
+ ...msg,
+ id: `MOMENTS_MOCHITEST_${Date.now()}`,
+ priority: 2,
+ targeting: "true",
+ }
+ );
+
+ // Reset so we can `await` for the pref value to be set again
+ Services.prefs.clearUserPref(HOMEPAGE_OVERRIDE_PREF);
+
+ let prevLength = ASRouter.state.messages.length;
+ // Wait to load the messages
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > prevLength,
+ "Messages did not load"
+ );
+
+ await MomentsPageHub.messageRequest({
+ triggerId: "momentsUpdate",
+ template: "update_action",
+ });
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "").length;
+ }, "Pref value was not set");
+
+ value = Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "");
+ is(
+ JSON.parse(value).url,
+ msg.content.action.data.url,
+ "Correct value set for higher priority message"
+ );
+
+ await client.db.clear();
+ // Wait to reset the WNPanel messages from state
+ const previousMessageCount = ASRouter.state.messages.length;
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length < previousMessageCount,
+ "ASRouter messages should have been removed"
+ );
+ await SpecialPowers.popPrefEnv();
+ // Reload the provider
+ await ASRouter._updateMessageProviders();
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js
new file mode 100644
index 0000000000..432b4b75a7
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js
@@ -0,0 +1,1706 @@
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+ AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
+ AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
+ ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
+ AttributionCode: "resource:///modules/AttributionCode.sys.mjs",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
+ CFRMessageProvider: "resource:///modules/asrouter/CFRMessageProvider.sys.mjs",
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
+ QueryCache: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ ShellService: "resource:///modules/ShellService.sys.mjs",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
+});
+
+function sendFormAutofillMessage(name, data) {
+ let actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "FormAutofill"
+ );
+ return actor.receiveMessage({ name, data });
+}
+
+async function removeAutofillRecords() {
+ let addresses = (
+ await sendFormAutofillMessage("FormAutofill:GetRecords", {
+ collectionName: "addresses",
+ })
+ ).records;
+ if (addresses.length) {
+ let observePromise = TestUtils.topicObserved(
+ "formautofill-storage-changed"
+ );
+ await sendFormAutofillMessage("FormAutofill:RemoveAddresses", {
+ guids: addresses.map(address => address.guid),
+ });
+ await observePromise;
+ }
+ let creditCards = (
+ await sendFormAutofillMessage("FormAutofill:GetRecords", {
+ collectionName: "creditCards",
+ })
+ ).records;
+ if (creditCards.length) {
+ let observePromise = TestUtils.topicObserved(
+ "formautofill-storage-changed"
+ );
+ await sendFormAutofillMessage("FormAutofill:RemoveCreditCards", {
+ guids: creditCards.map(cc => cc.guid),
+ });
+ await observePromise;
+ }
+}
+
+// ASRouterTargeting.findMatchingMessage
+add_task(async function find_matching_message() {
+ const messages = [
+ { id: "foo", targeting: "FOO" },
+ { id: "bar", targeting: "!FOO" },
+ ];
+ const context = { FOO: true };
+
+ const match = await ASRouterTargeting.findMatchingMessage({
+ messages,
+ context,
+ });
+
+ is(match, messages[0], "should match and return the correct message");
+});
+
+add_task(async function return_nothing_for_no_matching_message() {
+ const messages = [{ id: "bar", targeting: "!FOO" }];
+ const context = { FOO: true };
+
+ const match = await ASRouterTargeting.findMatchingMessage({
+ messages,
+ context,
+ });
+
+ ok(!match, "should return nothing since no matching message exists");
+});
+
+add_task(async function check_other_error_handling() {
+ let called = false;
+ function onError(...args) {
+ called = true;
+ }
+
+ const messages = [{ id: "foo", targeting: "foo" }];
+ const context = {
+ get foo() {
+ throw new Error("test error");
+ },
+ };
+ const match = await ASRouterTargeting.findMatchingMessage({
+ messages,
+ context,
+ onError,
+ });
+
+ ok(!match, "should return nothing since no valid matching message exists");
+
+ Assert.ok(called, "Attribute error caught");
+});
+
+// ASRouterTargeting.Environment
+add_task(async function check_locale() {
+ ok(
+ Services.locale.appLocaleAsBCP47,
+ "Services.locale.appLocaleAsBCP47 exists"
+ );
+ const message = {
+ id: "foo",
+ targeting: `locale == "${Services.locale.appLocaleAsBCP47}"`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by locale"
+ );
+});
+add_task(async function check_localeLanguageCode() {
+ const currentLanguageCode = Services.locale.appLocaleAsBCP47.substr(0, 2);
+ is(
+ Services.locale.negotiateLanguages(
+ [currentLanguageCode],
+ [Services.locale.appLocaleAsBCP47]
+ )[0],
+ Services.locale.appLocaleAsBCP47,
+ "currentLanguageCode should resolve to the current locale (e.g en => en-US)"
+ );
+ const message = {
+ id: "foo",
+ targeting: `localeLanguageCode == "${currentLanguageCode}"`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by localeLanguageCode"
+ );
+});
+
+add_task(async function checkProfileAgeCreated() {
+ let profileAccessor = await ProfileAge();
+ is(
+ await ASRouterTargeting.Environment.profileAgeCreated,
+ await profileAccessor.created,
+ "should return correct profile age creation date"
+ );
+
+ const message = {
+ id: "foo",
+ targeting: `profileAgeCreated > ${(await profileAccessor.created) - 100}`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by profile age created"
+ );
+});
+
+add_task(async function checkProfileAgeReset() {
+ let profileAccessor = await ProfileAge();
+ is(
+ await ASRouterTargeting.Environment.profileAgeReset,
+ await profileAccessor.reset,
+ "should return correct profile age reset"
+ );
+
+ const message = {
+ id: "foo",
+ targeting: `profileAgeReset == ${await profileAccessor.reset}`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by profile age reset"
+ );
+});
+
+add_task(async function checkCurrentDate() {
+ let message = {
+ id: "foo",
+ targeting: `currentDate < '${new Date(Date.now() + 5000)}'|date`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select message based on currentDate < timestamp"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `currentDate > '${new Date(Date.now() - 5000)}'|date`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select message based on currentDate > timestamp"
+ );
+});
+
+add_task(async function check_usesFirefoxSync() {
+ await pushPrefs(["services.sync.username", "someone@foo.com"]);
+ is(
+ await ASRouterTargeting.Environment.usesFirefoxSync,
+ true,
+ "should return true if a fx account is set"
+ );
+
+ const message = { id: "foo", targeting: "usesFirefoxSync" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by usesFirefoxSync"
+ );
+});
+
+add_task(async function check_isFxAEnabled() {
+ await pushPrefs(["identity.fxaccounts.enabled", false]);
+ is(
+ await ASRouterTargeting.Environment.isFxAEnabled,
+ false,
+ "should return false if fxa is disabled"
+ );
+
+ const message = { id: "foo", targeting: "isFxAEnabled" };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select a message if fxa is disabled"
+ );
+});
+
+add_task(async function check_isFxAEnabled() {
+ await pushPrefs(["identity.fxaccounts.enabled", true]);
+ is(
+ await ASRouterTargeting.Environment.isFxAEnabled,
+ true,
+ "should return true if fxa is enabled"
+ );
+
+ const message = { id: "foo", targeting: "isFxAEnabled" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select the correct message"
+ );
+});
+
+add_task(async function check_isFxASignedIn_false() {
+ await pushPrefs(
+ ["identity.fxaccounts.enabled", true],
+ ["services.sync.username", ""]
+ );
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => sandbox.restore());
+ sandbox.stub(FxAccounts.prototype, "getSignedInUser").resolves(null);
+ is(
+ await ASRouterTargeting.Environment.isFxASignedIn,
+ false,
+ "user should not appear signed in"
+ );
+
+ const message = { id: "foo", targeting: "isFxASignedIn" };
+ isnot(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should not select the message since user is not signed in"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function check_isFxASignedIn_true() {
+ await pushPrefs(
+ ["identity.fxaccounts.enabled", true],
+ ["services.sync.username", ""]
+ );
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => sandbox.restore());
+ sandbox.stub(FxAccounts.prototype, "getSignedInUser").resolves({});
+ is(
+ await ASRouterTargeting.Environment.isFxASignedIn,
+ true,
+ "user should appear signed in"
+ );
+
+ const message = { id: "foo", targeting: "isFxASignedIn" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select the correct message"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function check_totalBookmarksCount() {
+ // Make sure we remove default bookmarks so they don't interfere
+ await clearHistoryAndBookmarks();
+ const message = { id: "foo", targeting: "totalBookmarksCount > 0" };
+
+ const results = await ASRouterTargeting.findMatchingMessage({
+ messages: [message],
+ });
+ ok(
+ !(results ? JSON.stringify(results) : results),
+ "Should not select any message because bookmarks count is not 0"
+ );
+
+ const bookmark = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "foo",
+ url: "https://mozilla1.com/nowNew",
+ });
+
+ QueryCache.queries.TotalBookmarksCount.expire();
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "Should select correct item after bookmarks are added."
+ );
+
+ // Cleanup
+ await PlacesUtils.bookmarks.remove(bookmark.guid);
+});
+
+add_task(async function check_needsUpdate() {
+ QueryCache.queries.CheckBrowserNeedsUpdate.setUp(true);
+
+ const message = { id: "foo", targeting: "needsUpdate" };
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "Should select message because update count > 0"
+ );
+
+ QueryCache.queries.CheckBrowserNeedsUpdate.setUp(false);
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ null,
+ "Should not select message because update count == 0"
+ );
+});
+
+add_task(async function checksearchEngines() {
+ const result = await ASRouterTargeting.Environment.searchEngines;
+ const expectedInstalled = (await Services.search.getAppProvidedEngines())
+ .map(engine => engine.identifier)
+ .sort()
+ .join(",");
+ ok(
+ result.installed.length,
+ "searchEngines.installed should be a non-empty array"
+ );
+ is(
+ result.installed.sort().join(","),
+ expectedInstalled,
+ "searchEngines.installed should be an array of visible search engines"
+ );
+ ok(
+ result.current && typeof result.current === "string",
+ "searchEngines.current should be a truthy string"
+ );
+ is(
+ result.current,
+ (await Services.search.getDefault()).identifier,
+ "searchEngines.current should be the current engine name"
+ );
+
+ const message = {
+ id: "foo",
+ targeting: `searchEngines[.current == ${
+ (await Services.search.getDefault()).identifier
+ }]`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by searchEngines.current"
+ );
+
+ const message2 = {
+ id: "foo",
+ targeting: `searchEngines[${
+ (await Services.search.getAppProvidedEngines())[0].identifier
+ } in .installed]`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message2] }),
+ message2,
+ "should select correct item by searchEngines.installed"
+ );
+});
+
+add_task(async function checkisDefaultBrowser() {
+ const expected = ShellService.isDefaultBrowser();
+ const result = await ASRouterTargeting.Environment.isDefaultBrowser;
+ is(typeof result, "boolean", "isDefaultBrowser should be a boolean value");
+ is(
+ result,
+ expected,
+ "isDefaultBrowser should be equal to ShellService.isDefaultBrowser()"
+ );
+ const message = {
+ id: "foo",
+ targeting: `isDefaultBrowser == ${expected.toString()}`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by isDefaultBrowser"
+ );
+});
+
+add_task(async function checkdevToolsOpenedCount() {
+ await pushPrefs(["devtools.selfxss.count", 5]);
+ is(
+ ASRouterTargeting.Environment.devToolsOpenedCount,
+ 5,
+ "devToolsOpenedCount should be equal to devtools.selfxss.count pref value"
+ );
+ const message = { id: "foo", targeting: "devToolsOpenedCount >= 5" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by devToolsOpenedCount"
+ );
+});
+
+add_task(async function check_platformName() {
+ const message = {
+ id: "foo",
+ targeting: `platformName == "${AppConstants.platform}"`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by platformName"
+ );
+});
+
+AddonTestUtils.initMochitest(this);
+
+add_task(async function checkAddonsInfo() {
+ const FAKE_ID = "testaddon@tests.mozilla.org";
+ const FAKE_NAME = "Test Addon";
+ const FAKE_VERSION = "0.5.7";
+
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: FAKE_ID } },
+ name: FAKE_NAME,
+ version: FAKE_VERSION,
+ },
+ });
+
+ await Promise.all([
+ AddonTestUtils.promiseWebExtensionStartup(FAKE_ID),
+ AddonManager.installTemporaryAddon(xpi),
+ ]);
+
+ const { addons } = await AddonManager.getActiveAddons([
+ "extension",
+ "service",
+ ]);
+
+ const { addons: asRouterAddons, isFullData } = await ASRouterTargeting
+ .Environment.addonsInfo;
+
+ ok(
+ addons.every(({ id }) => asRouterAddons[id]),
+ "should contain every addon"
+ );
+
+ ok(
+ Object.getOwnPropertyNames(asRouterAddons).every(id =>
+ addons.some(addon => addon.id === id)
+ ),
+ "should contain no incorrect addons"
+ );
+
+ const testAddon = asRouterAddons[FAKE_ID];
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "version") &&
+ testAddon.version === FAKE_VERSION,
+ "should correctly provide `version` property"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "type") &&
+ testAddon.type === "extension",
+ "should correctly provide `type` property"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "isSystem") &&
+ testAddon.isSystem === false,
+ "should correctly provide `isSystem` property"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "isWebExtension") &&
+ testAddon.isWebExtension === true,
+ "should correctly provide `isWebExtension` property"
+ );
+
+ // As we installed our test addon the addons database must be initialised, so
+ // (in this test environment) we expect to receive "full" data
+
+ ok(isFullData, "should receive full data");
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "name") &&
+ testAddon.name === FAKE_NAME,
+ "should correctly provide `name` property from full data"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "userDisabled") &&
+ testAddon.userDisabled === false,
+ "should correctly provide `userDisabled` property from full data"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "installDate") &&
+ Math.abs(Date.now() - new Date(testAddon.installDate)) < 60 * 1000,
+ "should correctly provide `installDate` property from full data"
+ );
+});
+
+add_task(async function checkFrecentSites() {
+ const now = Date.now();
+ const timeDaysAgo = numDays => now - numDays * 24 * 60 * 60 * 1000;
+
+ const visits = [];
+ for (const [uri, count, visitDate] of [
+ ["https://mozilla1.com/", 10, timeDaysAgo(0)], // frecency 1000
+ ["https://mozilla2.com/", 5, timeDaysAgo(1)], // frecency 500
+ ["https://mozilla3.com/", 1, timeDaysAgo(2)], // frecency 100
+ ]) {
+ [...Array(count).keys()].forEach(() =>
+ visits.push({
+ uri,
+ visitDate: visitDate * 1000, // Places expects microseconds
+ })
+ );
+ }
+
+ await PlacesTestUtils.addVisits(visits);
+
+ let message = {
+ id: "foo",
+ targeting: "'mozilla3.com' in topFrecentSites|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by host in topFrecentSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting: "'non-existent.com' in topFrecentSites|mapToProperty('host')",
+ };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select incorrect item by host in topFrecentSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting:
+ "'mozilla2.com' in topFrecentSites[.frecency >= 400]|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by frecency"
+ );
+
+ message = {
+ id: "foo",
+ targeting:
+ "'mozilla2.com' in topFrecentSites[.frecency >= 600]|mapToProperty('host')",
+ };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select incorrect item when filtering by frecency"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${
+ timeDaysAgo(1) - 1
+ }]|mapToProperty('host')`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by lastVisitDate"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${
+ timeDaysAgo(0) - 1
+ }]|mapToProperty('host')`,
+ };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select incorrect item when filtering by lastVisitDate"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `(topFrecentSites[.frecency >= 900 && .lastVisitDate >= ${
+ timeDaysAgo(1) - 1
+ }]|mapToProperty('host') intersect ['mozilla3.com', 'mozilla2.com', 'mozilla1.com'])|length > 0`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by frecency and lastVisitDate with multiple candidate domains"
+ );
+
+ // Cleanup
+ await clearHistoryAndBookmarks();
+});
+
+add_task(async function check_pinned_sites() {
+ // Fresh profiles come with an empty set of pinned websites (pref doesn't
+ // exist). Search shortcut topsites make this test more complicated because
+ // the feature pins a new website on startup. Behaviour can vary when running
+ // with --verify so it's more predictable to clear pins entirely.
+ Services.prefs.clearUserPref("browser.newtabpage.pinned");
+ NewTabUtils.pinnedLinks.resetCache();
+ const originalPin = JSON.stringify(NewTabUtils.pinnedLinks.links);
+ const sitesToPin = [
+ { url: "https://foo.com" },
+ { url: "https://bloo.com" },
+ { url: "https://floogle.com", searchTopSite: true },
+ ];
+ sitesToPin.forEach(site =>
+ NewTabUtils.pinnedLinks.pin(site, NewTabUtils.pinnedLinks.links.length)
+ );
+
+ // Unpinning adds null to the list of pinned sites, which we should test that we handle gracefully for our targeting
+ NewTabUtils.pinnedLinks.unpin(sitesToPin[1]);
+ ok(
+ NewTabUtils.pinnedLinks.links.includes(null),
+ "should have set an item in pinned links to null via unpinning for testing"
+ );
+
+ let message;
+
+ message = {
+ id: "foo",
+ targeting: "'https://foo.com' in pinnedSites|mapToProperty('url')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by url in pinnedSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting: "'foo.com' in pinnedSites|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by host in pinnedSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting:
+ "'floogle.com' in pinnedSites[.searchTopSite == true]|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by host and searchTopSite in pinnedSites"
+ );
+
+ // Cleanup
+ sitesToPin.forEach(site => NewTabUtils.pinnedLinks.unpin(site));
+
+ await clearHistoryAndBookmarks();
+ Services.prefs.clearUserPref("browser.newtabpage.pinned");
+ NewTabUtils.pinnedLinks.resetCache();
+ is(
+ JSON.stringify(NewTabUtils.pinnedLinks.links),
+ originalPin,
+ "should restore pinned sites to its original state"
+ );
+});
+
+add_task(async function check_firefox_version() {
+ const message = { id: "foo", targeting: "firefoxVersion > 0" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by firefox version"
+ );
+});
+
+add_task(async function check_region() {
+ Region._setHomeRegion("DE", false);
+ const message = { id: "foo", targeting: "region in ['DE']" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by firefox geo"
+ );
+});
+
+add_task(async function check_browserSettings() {
+ is(
+ await JSON.stringify(ASRouterTargeting.Environment.browserSettings.update),
+ JSON.stringify(TelemetryEnvironment.currentEnvironment.settings.update),
+ "should return correct update info"
+ );
+});
+
+add_task(async function check_sync() {
+ is(
+ await ASRouterTargeting.Environment.sync.desktopDevices,
+ Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0),
+ "should return correct desktopDevices info"
+ );
+ is(
+ await ASRouterTargeting.Environment.sync.mobileDevices,
+ Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0),
+ "should return correct mobileDevices info"
+ );
+ is(
+ await ASRouterTargeting.Environment.sync.totalDevices,
+ Services.prefs.getIntPref("services.sync.numClients", 0),
+ "should return correct mobileDevices info"
+ );
+});
+
+add_task(async function check_provider_cohorts() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.providers.onboarding",
+ JSON.stringify({
+ id: "onboarding",
+ messages: [],
+ enabled: true,
+ cohort: "foo",
+ }),
+ ]);
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ JSON.stringify({ id: "cfr", enabled: true, cohort: "bar" }),
+ ]);
+ is(
+ await ASRouterTargeting.Environment.providerCohorts.onboarding,
+ "foo",
+ "should have cohort foo for onboarding"
+ );
+ is(
+ await ASRouterTargeting.Environment.providerCohorts.cfr,
+ "bar",
+ "should have cohort bar for cfr"
+ );
+});
+
+add_task(async function check_xpinstall_enabled() {
+ // should default to true if pref doesn't exist
+ is(await ASRouterTargeting.Environment.xpinstallEnabled, true);
+ // flip to false, check targeting reflects that
+ await pushPrefs(["xpinstall.enabled", false]);
+ is(await ASRouterTargeting.Environment.xpinstallEnabled, false);
+ // flip to true, check targeting reflects that
+ await pushPrefs(["xpinstall.enabled", true]);
+ is(await ASRouterTargeting.Environment.xpinstallEnabled, true);
+});
+
+add_task(async function check_pinned_tabs() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ is(
+ await ASRouterTargeting.Environment.hasPinnedTabs,
+ false,
+ "No pin tabs yet"
+ );
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ gBrowser.pinTab(tab);
+
+ is(
+ await ASRouterTargeting.Environment.hasPinnedTabs,
+ true,
+ "Should detect pinned tab"
+ );
+
+ gBrowser.unpinTab(tab);
+ }
+ );
+});
+
+add_task(async function check_hasAccessedFxAPanel() {
+ is(
+ await ASRouterTargeting.Environment.hasAccessedFxAPanel,
+ false,
+ "Not accessed yet"
+ );
+
+ await pushPrefs(["identity.fxaccounts.toolbar.accessed", true]);
+
+ is(
+ await ASRouterTargeting.Environment.hasAccessedFxAPanel,
+ true,
+ "Should detect panel access"
+ );
+});
+
+add_task(async function checkCFRFeaturesUserPref() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ false,
+ ]);
+ is(
+ ASRouterTargeting.Environment.userPrefs.cfrFeatures,
+ false,
+ "cfrFeature should be false according to pref"
+ );
+ const message = { id: "foo", targeting: "userPrefs.cfrFeatures == false" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by cfrFeature"
+ );
+});
+
+add_task(async function checkCFRAddonsUserPref() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
+ false,
+ ]);
+ is(
+ ASRouterTargeting.Environment.userPrefs.cfrAddons,
+ false,
+ "cfrFeature should be false according to pref"
+ );
+ const message = { id: "foo", targeting: "userPrefs.cfrAddons == false" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by cfrAddons"
+ );
+});
+
+add_task(async function check_blockedCountByType() {
+ const message = {
+ id: "foo",
+ targeting:
+ "blockedCountByType.cryptominerCount == 0 && blockedCountByType.socialCount == 0",
+ };
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item"
+ );
+});
+
+add_task(async function checkPatternMatches() {
+ const now = Date.now();
+ const timeMinutesAgo = numMinutes => now - numMinutes * 60 * 1000;
+ const messages = [
+ {
+ id: "message_with_pattern",
+ targeting: "true",
+ trigger: { id: "frequentVisits", patterns: ["*://*.github.com/"] },
+ },
+ ];
+ const trigger = {
+ id: "frequentVisits",
+ context: {
+ recentVisits: [
+ { timestamp: timeMinutesAgo(33) },
+ { timestamp: timeMinutesAgo(17) },
+ { timestamp: timeMinutesAgo(1) },
+ ],
+ },
+ param: { host: "github.com", url: "https://gist.github.com" },
+ };
+
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages, trigger })).id,
+ "message_with_pattern",
+ "should select PIN_TAB mesage"
+ );
+});
+
+add_task(async function checkPatternsValid() {
+ const messages = (await CFRMessageProvider.getMessages()).filter(
+ m => m.trigger?.patterns
+ );
+
+ for (const message of messages) {
+ Assert.ok(new MatchPatternSet(message.trigger.patterns));
+ }
+});
+
+add_task(async function check_isChinaRepack() {
+ const prefDefaultBranch = Services.prefs.getDefaultBranch("distribution.");
+ const messages = [
+ { id: "msg_for_china_repack", targeting: "isChinaRepack == true" },
+ { id: "msg_for_everyone_else", targeting: "isChinaRepack == false" },
+ ];
+
+ is(
+ await ASRouterTargeting.Environment.isChinaRepack,
+ false,
+ "Fx w/o partner repack info set is not China repack"
+ );
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages })).id,
+ "msg_for_everyone_else",
+ "should select the message for non China repack users"
+ );
+
+ prefDefaultBranch.setCharPref("id", "MozillaOnline");
+
+ is(
+ await ASRouterTargeting.Environment.isChinaRepack,
+ true,
+ "Fx with `distribution.id` set to `MozillaOnline` is China repack"
+ );
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages })).id,
+ "msg_for_china_repack",
+ "should select the message for China repack users"
+ );
+
+ prefDefaultBranch.setCharPref("id", "Example");
+
+ is(
+ await ASRouterTargeting.Environment.isChinaRepack,
+ false,
+ "Fx with `distribution.id` set to other string is not China repack"
+ );
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages })).id,
+ "msg_for_everyone_else",
+ "should select the message for non China repack users"
+ );
+
+ prefDefaultBranch.deleteBranch("");
+});
+
+add_task(async function check_userId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.normandy.user_id", "foo123"]],
+ });
+ is(
+ await ASRouterTargeting.Environment.userId,
+ "foo123",
+ "should read userID from normandy user id pref"
+ );
+});
+
+add_task(async function check_profileRestartCount() {
+ ok(
+ !isNaN(ASRouterTargeting.Environment.profileRestartCount),
+ "it should return a number"
+ );
+});
+
+add_task(async function check_homePageSettings_default() {
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(settings.isDefault, "should set as default");
+ ok(!settings.isLocked, "should not set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should not be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, "about:home", "should be about:home");
+ is(settings.urls[0].host, "", "should be an empty string");
+});
+
+add_task(async function check_homePageSettings_locked() {
+ const PREF = "browser.startup.homepage";
+ Services.prefs.lockPref(PREF);
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(settings.isDefault, "should set as default");
+ ok(settings.isLocked, "should set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should not be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, "about:home", "should be about:home");
+ is(settings.urls[0].host, "", "should be an empty string");
+ Services.prefs.unlockPref(PREF);
+});
+
+add_task(async function check_homePageSettings_customURL() {
+ await HomePage.set("https://www.google.com");
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isLocked, "should set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(settings.isCustomUrl, "should be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, "https://www.google.com", "should be a custom URL");
+ is(
+ settings.urls[0].host,
+ "google.com",
+ "should be the host name without 'www.'"
+ );
+
+ HomePage.reset();
+});
+
+add_task(async function check_homePageSettings_customURL_multiple() {
+ await HomePage.set("https://www.google.com|https://www.youtube.com");
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isLocked, "should not set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(settings.isCustomUrl, "should be custom URL");
+ is(settings.urls.length, 2, "should be a 2-entry array");
+ is(settings.urls[0].url, "https://www.google.com", "should be a custom URL");
+ is(
+ settings.urls[0].host,
+ "google.com",
+ "should be the host name without 'www.'"
+ );
+ is(settings.urls[1].url, "https://www.youtube.com", "should be a custom URL");
+ is(
+ settings.urls[1].host,
+ "youtube.com",
+ "should be the host name without 'www.'"
+ );
+
+ HomePage.reset();
+});
+
+add_task(async function check_homePageSettings_webExtension() {
+ const extURI =
+ "moz-extension://0d735548-ba3c-aa43-a0e4-7089584fbb53/homepage.html";
+ await HomePage.set(extURI);
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isLocked, "should not set as locked");
+ ok(settings.isWebExt, "should be a web extension");
+ ok(!settings.isCustomUrl, "should be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, extURI, "should be a webExtension URI");
+ is(settings.urls[0].host, "", "should be an empty string");
+
+ HomePage.reset();
+});
+
+add_task(async function check_newtabSettings_default() {
+ let settings = ASRouterTargeting.Environment.newtabSettings;
+
+ ok(settings.isDefault, "should set as default");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should not be custom URL");
+ is(settings.url, "about:newtab", "should be about:home");
+ is(settings.host, "", "should be an empty string");
+});
+
+add_task(async function check_newTabSettings_customURL() {
+ AboutNewTab.newTabURL = "https://www.google.com";
+ let settings = ASRouterTargeting.Environment.newtabSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(settings.isCustomUrl, "should be custom URL");
+ is(settings.url, "https://www.google.com", "should be a custom URL");
+ is(settings.host, "google.com", "should be the host name without 'www.'");
+
+ AboutNewTab.resetNewTabURL();
+});
+
+add_task(async function check_newTabSettings_webExtension() {
+ const extURI =
+ "moz-extension://0d735548-ba3c-aa43-a0e4-7089584fbb53/homepage.html";
+ AboutNewTab.newTabURL = extURI;
+ let settings = ASRouterTargeting.Environment.newtabSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should be custom URL");
+ is(settings.url, extURI, "should be the web extension URI");
+ is(settings.host, "", "should be an empty string");
+
+ AboutNewTab.resetNewTabURL();
+});
+
+add_task(async function check_openUrlTrigger_context() {
+ const message = {
+ ...(await CFRMessageProvider.getMessages()).find(
+ m => m.id === "YOUTUBE_ENHANCE_3"
+ ),
+ targeting: "visitsCount == 3",
+ };
+ const trigger = {
+ id: "openURL",
+ context: { visitsCount: 3 },
+ param: { host: "youtube.com", url: "https://www.youtube.com" },
+ };
+
+ is(
+ (
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [message],
+ trigger,
+ })
+ ).id,
+ message.id,
+ `should select ${message.id} mesage`
+ );
+});
+
+add_task(async function check_is_major_upgrade() {
+ let message = {
+ id: "check_is_major_upgrade",
+ targeting: `isMajorUpgrade != undefined && isMajorUpgrade == ${
+ Cc["@mozilla.org/browser/clh;1"].getService(Ci.nsIBrowserHandler)
+ .majorUpgrade
+ }`,
+ };
+
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages: [message] })).id,
+ message.id,
+ "Should select the message"
+ );
+});
+
+add_task(async function check_userMonthlyActivity() {
+ ok(
+ Array.isArray(await ASRouterTargeting.Environment.userMonthlyActivity),
+ "value is an array"
+ );
+});
+
+add_task(async function check_doesAppNeedPin() {
+ is(
+ typeof (await ASRouterTargeting.Environment.doesAppNeedPin),
+ "boolean",
+ "Should return a boolean"
+ );
+});
+
+add_task(async function check_doesAppNeedPrivatePin() {
+ is(
+ typeof (await ASRouterTargeting.Environment.doesAppNeedPrivatePin),
+ "boolean",
+ "Should return a boolean"
+ );
+});
+
+add_task(async function check_isBackgroundTaskMode() {
+ if (!AppConstants.MOZ_BACKGROUNDTASKS) {
+ // `mochitest-browser` suite `add_task` does not yet support
+ // `properties.skip_if`.
+ ok(true, "Skipping because !AppConstants.MOZ_BACKGROUNDTASKS");
+ return;
+ }
+
+ const bts = Cc["@mozilla.org/backgroundtasks;1"].getService(
+ Ci.nsIBackgroundTasks
+ );
+
+ // Pretend that this is a background task.
+ bts.overrideBackgroundTaskNameForTesting("taskName");
+ is(
+ await ASRouterTargeting.Environment.isBackgroundTaskMode,
+ true,
+ "Is in background task mode"
+ );
+ is(
+ await ASRouterTargeting.Environment.backgroundTaskName,
+ "taskName",
+ "Has expected background task name"
+ );
+
+ // Unset, so that subsequent test functions don't see background task mode.
+ bts.overrideBackgroundTaskNameForTesting(null);
+ is(
+ await ASRouterTargeting.Environment.isBackgroundTaskMode,
+ false,
+ "Is not in background task mode"
+ );
+ is(
+ await ASRouterTargeting.Environment.backgroundTaskName,
+ null,
+ "Has no background task name"
+ );
+});
+
+add_task(async function check_userPrefersReducedMotion() {
+ is(
+ typeof (await ASRouterTargeting.Environment.userPrefersReducedMotion),
+ "boolean",
+ "Should return a boolean"
+ );
+});
+
+add_task(async function test_mr2022Holdback() {
+ await ExperimentAPI.ready();
+
+ ok(
+ !ASRouterTargeting.Environment.inMr2022Holdback,
+ "Should not be in holdback (no experiment)"
+ );
+
+ {
+ const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "majorRelease2022",
+ value: {
+ onboarding: true,
+ },
+ });
+
+ ok(
+ !ASRouterTargeting.Environment.inMr2022Holdback,
+ "Should not be in holdback (onboarding = true)"
+ );
+
+ await doExperimentCleanup();
+ }
+
+ {
+ const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "majorRelease2022",
+ value: {
+ onboarding: false,
+ },
+ });
+
+ ok(
+ ASRouterTargeting.Environment.inMr2022Holdback,
+ "Should be in holdback (onboarding = false)"
+ );
+
+ await doExperimentCleanup();
+ }
+});
+
+add_task(async function test_distributionId() {
+ is(
+ ASRouterTargeting.Environment.distributionId,
+ "",
+ "Should return an empty distribution Id"
+ );
+
+ Services.prefs.getDefaultBranch(null).setCharPref("distribution.id", "test");
+
+ is(
+ ASRouterTargeting.Environment.distributionId,
+ "test",
+ "Should return the correct distribution Id"
+ );
+});
+
+add_task(async function test_fxViewButtonAreaType_default() {
+ is(
+ typeof (await ASRouterTargeting.Environment.fxViewButtonAreaType),
+ "string",
+ "Should return a string"
+ );
+
+ is(
+ await ASRouterTargeting.Environment.fxViewButtonAreaType,
+ "toolbar",
+ "Should return name of container if button hasn't been removed"
+ );
+});
+
+add_task(async function test_fxViewButtonAreaType_removed() {
+ CustomizableUI.removeWidgetFromArea("firefox-view-button");
+
+ is(
+ await ASRouterTargeting.Environment.fxViewButtonAreaType,
+ null,
+ "Should return null if button has been removed"
+ );
+ CustomizableUI.reset();
+});
+
+add_task(async function test_creditCardsSaved() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.creditCards.supported", "on"],
+ ["extensions.formautofill.creditCards.enabled", true],
+ ],
+ });
+
+ is(
+ await ASRouterTargeting.Environment.creditCardsSaved,
+ 0,
+ "Should return 0 when no credit cards are saved"
+ );
+
+ let creditcard = {
+ "cc-name": "Test User",
+ "cc-number": "5038146897157463",
+ "cc-exp-month": "11",
+ "cc-exp-year": "20",
+ };
+
+ // Intermittently fails on macOS, likely related to Bug 1714221. So, mock the
+ // autofill actor.
+ if (AppConstants.platform === "macosx") {
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => sandbox.restore());
+ let stub = sandbox
+ .stub(
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "FormAutofill"
+ ),
+ "receiveMessage"
+ )
+ .withArgs(
+ sandbox.match({
+ name: "FormAutofill:GetRecords",
+ data: { collectionName: "creditCards" },
+ })
+ )
+ .resolves({ records: [creditcard] })
+ .callThrough();
+
+ is(
+ await ASRouterTargeting.Environment.creditCardsSaved,
+ 1,
+ "Should return 1 when 1 credit card is saved"
+ );
+ ok(
+ stub.calledWithMatch({ name: "FormAutofill:GetRecords" }),
+ "Targeting called FormAutofill:GetRecords"
+ );
+
+ sandbox.restore();
+ } else {
+ let observePromise = TestUtils.topicObserved(
+ "formautofill-storage-changed"
+ );
+ await sendFormAutofillMessage("FormAutofill:SaveCreditCard", {
+ creditcard,
+ });
+ await observePromise;
+
+ is(
+ await ASRouterTargeting.Environment.creditCardsSaved,
+ 1,
+ "Should return 1 when 1 credit card is saved"
+ );
+ await removeAutofillRecords();
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_addressesSaved() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.addresses.supported", "on"],
+ ["extensions.formautofill.addresses.enabled", true],
+ ],
+ });
+
+ is(
+ await ASRouterTargeting.Environment.addressesSaved,
+ 0,
+ "Should return 0 when no addresses are saved"
+ );
+
+ let observePromise = TestUtils.topicObserved("formautofill-storage-changed");
+ await sendFormAutofillMessage("FormAutofill:SaveAddress", {
+ address: {
+ "given-name": "John",
+ "additional-name": "R.",
+ "family-name": "Smith",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+ },
+ });
+ await observePromise;
+
+ is(
+ await ASRouterTargeting.Environment.addressesSaved,
+ 1,
+ "Should return 1 when 1 address is saved"
+ );
+
+ await removeAutofillRecords();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_migrationInteractions() {
+ const PREF_GETTER_MAPPING = new Map([
+ ["browser.migrate.interactions.bookmarks", "hasMigratedBookmarks"],
+ ["browser.migrate.interactions.csvpasswords", "hasMigratedCSVPasswords"],
+ ["browser.migrate.interactions.history", "hasMigratedHistory"],
+ ["browser.migrate.interactions.passwords", "hasMigratedPasswords"],
+ ]);
+
+ for (let [pref, getterName] of PREF_GETTER_MAPPING) {
+ await pushPrefs([pref, false]);
+ ok(!(await ASRouterTargeting.Environment[getterName]));
+ await pushPrefs([pref, true]);
+ ok(await ASRouterTargeting.Environment[getterName]);
+ }
+});
+
+add_task(async function check_useEmbeddedMigrationWizard() {
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "default",
+ ]);
+
+ ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard));
+
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "autoclose",
+ ]);
+
+ ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard));
+
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "embedded",
+ ]);
+
+ ok(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard);
+
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "standalone",
+ ]);
+
+ ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard));
+});
+
+add_task(async function check_isRTAMO() {
+ is(
+ typeof ASRouterTargeting.Environment.isRTAMO,
+ "boolean",
+ "Should return a boolean"
+ );
+
+ const TEST_CASES = [
+ {
+ title: "no attribution data",
+ attributionData: {},
+ expected: false,
+ },
+ {
+ title: "null attribution data",
+ attributionData: null,
+ expected: false,
+ },
+ {
+ title: "no content",
+ attributionData: {
+ source: "addons.mozilla.org",
+ },
+ expected: false,
+ },
+ {
+ title: "empty content",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null content",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: null,
+ },
+ expected: false,
+ },
+ {
+ title: "empty source",
+ attributionData: {
+ source: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null source",
+ attributionData: {
+ source: null,
+ },
+ expected: false,
+ },
+ {
+ title: "valid attribution data for RTAMO with content not encoded",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "rta:<encoded-addon-id>",
+ },
+ expected: true,
+ },
+ {
+ title: "valid attribution data for RTAMO with content encoded once",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "rta%3A<encoded-addon-id>",
+ },
+ expected: true,
+ },
+ {
+ title: "valid attribution data for RTAMO with content encoded twice",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "rta%253A<encoded-addon-id>",
+ },
+ expected: true,
+ },
+ {
+ title: "invalid source",
+ attributionData: {
+ source: "www.mozilla.org",
+ content: "rta%3A<encoded-addon-id>",
+ },
+ expected: false,
+ },
+ ];
+
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => {
+ sandbox.restore();
+ });
+
+ const stub = sandbox.stub(AttributionCode, "getCachedAttributionData");
+
+ for (const { title, attributionData, expected } of TEST_CASES) {
+ stub.returns(attributionData);
+
+ is(
+ ASRouterTargeting.Environment.isRTAMO,
+ expected,
+ `${title} - Expected isRTAMO to have the expected value`
+ );
+ }
+
+ sandbox.restore();
+});
+
+add_task(async function check_isDeviceMigration() {
+ is(
+ typeof ASRouterTargeting.Environment.isDeviceMigration,
+ "boolean",
+ "Should return a boolean"
+ );
+
+ const TEST_CASES = [
+ {
+ title: "no attribution data",
+ attributionData: {},
+ expected: false,
+ },
+ {
+ title: "null attribution data",
+ attributionData: null,
+ expected: false,
+ },
+ {
+ title: "no campaign",
+ attributionData: {
+ source: "support.mozilla.org",
+ },
+ expected: false,
+ },
+ {
+ title: "empty campaign",
+ attributionData: {
+ source: "support.mozilla.org",
+ campaign: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null campaign",
+ attributionData: {
+ source: "addons.mozilla.org",
+ campaign: null,
+ },
+ expected: false,
+ },
+ {
+ title: "empty source",
+ attributionData: {
+ source: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null source",
+ attributionData: {
+ source: null,
+ },
+ expected: false,
+ },
+ {
+ title: "other source",
+ attributionData: {
+ source: "www.mozilla.org",
+ campaign: "migration",
+ },
+ expected: true,
+ },
+ {
+ title: "valid attribution data for isDeviceMigration",
+ attributionData: {
+ source: "support.mozilla.org",
+ campaign: "migration",
+ },
+ expected: true,
+ },
+ ];
+
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => {
+ sandbox.restore();
+ });
+
+ const stub = sandbox.stub(AttributionCode, "getCachedAttributionData");
+
+ for (const { title, attributionData, expected } of TEST_CASES) {
+ stub.returns(attributionData);
+
+ is(
+ ASRouterTargeting.Environment.isDeviceMigration,
+ expected,
+ `${title} - Expected isDeviceMigration to have the expected value`
+ );
+ }
+
+ sandbox.restore();
+});
+
+add_task(async function check_primaryResolution() {
+ is(
+ typeof ASRouterTargeting.Environment.primaryResolution,
+ "object",
+ "Should return an object"
+ );
+
+ is(
+ typeof ASRouterTargeting.Environment.primaryResolution.width,
+ "number",
+ "Width property should return a number"
+ );
+
+ is(
+ typeof ASRouterTargeting.Environment.primaryResolution.height,
+ "number",
+ "Height property should return a number"
+ );
+});
+
+add_task(async function check_archBits() {
+ const bits = ASRouterTargeting.Environment.archBits;
+ is(typeof bits, "number", "archBits should be a number");
+ ok(bits === 32 || bits === 64, "archBits is either 32 or 64");
+});
+
+add_task(async function check_memoryMB() {
+ const memory = ASRouterTargeting.Environment.memoryMB;
+ is(typeof memory, "number", "Memory is a number");
+ // To make sure we get a sensible number we verify that whatever system
+ // runs this unit test it has between 500MB and 1TB of RAM.
+ ok(memory > 500 && memory < 5_000_000);
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js b/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js
new file mode 100644
index 0000000000..2c1adb477b
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// At the time of writing, toast notifications (including XUL notifications)
+// don't support action buttons, so there's little to be tested here beyond
+// display.
+
+"use strict";
+
+const { ToastNotification } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ToastNotification.sys.mjs"
+);
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/PanelTestProvider.sys.mjs"
+);
+
+function getMessage(id) {
+ return PanelTestProvider.getMessages().then(msgs =>
+ msgs.find(m => m.id === id)
+ );
+}
+
+// Ensure we don't fall back to a real implementation.
+const showAlertStub = sinon.stub();
+const AlertsServiceStub = sinon.stub(ToastNotification, "AlertsService").value({
+ showAlert: showAlertStub,
+});
+
+registerCleanupFunction(() => {
+ AlertsServiceStub.restore();
+});
+
+// Test that toast notifications do, in fact, invoke the AlertsService. These
+// tests don't *need* to be `browser` tests, but we may eventually be able to
+// interact with the XUL notification elements, which would require `browser`
+// tests, so we follow suit with the equivalent `Spotlight`, etc, tests and use
+// the `browser` framework.
+add_task(async function test_showAlert() {
+ const l10n = new Localization([
+ "branding/brand.ftl",
+ "browser/newtab/asrouter.ftl",
+ ]);
+ let expectedTitle = await l10n.formatValue(
+ "cfr-doorhanger-bookmark-fxa-header"
+ );
+
+ showAlertStub.reset();
+
+ let dispatchStub = sinon.stub();
+
+ let message = await getMessage("TEST_TOAST_NOTIFICATION1");
+ await ToastNotification.showToastNotification(message, dispatchStub);
+
+ // Test display.
+ Assert.equal(
+ showAlertStub.callCount,
+ 1,
+ "AlertsService.showAlert is invoked"
+ );
+
+ let [alert] = showAlertStub.firstCall.args;
+ Assert.equal(alert.title, expectedTitle, "Should match");
+ Assert.equal(alert.text, "Body", "Should match");
+ Assert.equal(alert.name, "test_toast_notification", "Should match");
+});
+
+// Test that the `title` of each `action` of a toast notification is localized.
+add_task(async function test_actionLocalization() {
+ const l10n = new Localization([
+ "branding/brand.ftl",
+ "browser/newtab/asrouter.ftl",
+ ]);
+ let expectedTitle = await l10n.formatValue(
+ "mr2022-background-update-toast-title"
+ );
+ let expectedText = await l10n.formatValue(
+ "mr2022-background-update-toast-text"
+ );
+ let expectedPrimary = await l10n.formatValue(
+ "mr2022-background-update-toast-primary-button-label"
+ );
+ let expectedSecondary = await l10n.formatValue(
+ "mr2022-background-update-toast-secondary-button-label"
+ );
+
+ showAlertStub.reset();
+
+ let dispatchStub = sinon.stub();
+
+ let message = await getMessage("MR2022_BACKGROUND_UPDATE_TOAST_NOTIFICATION");
+ await ToastNotification.showToastNotification(message, dispatchStub);
+
+ // Test display.
+ Assert.equal(
+ showAlertStub.callCount,
+ 1,
+ "AlertsService.showAlert is invoked"
+ );
+
+ let [alert] = showAlertStub.firstCall.args;
+ Assert.equal(alert.title, expectedTitle, "Should match title");
+ Assert.equal(alert.text, expectedText, "Should match text");
+ Assert.equal(alert.name, "mr2022_background_update", "Should match");
+ Assert.equal(alert.actions[0].title, expectedPrimary, "Should match primary");
+ Assert.equal(
+ alert.actions[1].title,
+ expectedSecondary,
+ "Should match secondary"
+ );
+});
+
+// Test that toast notifications report sensible telemetry.
+add_task(async function test_telemetry() {
+ let dispatchStub = sinon.stub();
+
+ let message = await getMessage("TEST_TOAST_NOTIFICATION1");
+ await ToastNotification.showToastNotification(message, dispatchStub);
+
+ Assert.equal(
+ dispatchStub.callCount,
+ 2,
+ "1 IMPRESSION and 1 TOAST_NOTIFICATION_TELEMETRY"
+ );
+ Assert.equal(
+ dispatchStub.firstCall.args[0].type,
+ "TOAST_NOTIFICATION_TELEMETRY",
+ "Should match"
+ );
+ Assert.equal(
+ dispatchStub.firstCall.args[0].data.event,
+ "IMPRESSION",
+ "Should match"
+ );
+ Assert.equal(
+ dispatchStub.secondCall.args[0].type,
+ "IMPRESSION",
+ "Should match"
+ );
+});
diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js b/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js
new file mode 100644
index 0000000000..90e0d5ceeb
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js
@@ -0,0 +1,149 @@
+const { OnboardingMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs"
+);
+const { ToolbarBadgeHub } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs"
+);
+
+add_task(async function test_setup() {
+ // Cleanup pref value because we click the fxa accounts button.
+ // This is not required during tests because we "force show" the message
+ // by sending it directly to the Hub bypassing targeting.
+ registerCleanupFunction(() => {
+ // Clicking on the Firefox Accounts button while in the signed out
+ // state opens a new tab for signing in.
+ // We'll clean those up here for now.
+ gBrowser.removeAllTabsBut(gBrowser.tabs[0]);
+ // Stop the load in the last tab that remains.
+ gBrowser.stop();
+ Services.prefs.clearUserPref("identity.fxaccounts.toolbar.accessed");
+ });
+});
+
+add_task(async function test_fxa_badge_shown_nodelay() {
+ const [msg] = (await OnboardingMessageProvider.getMessages()).filter(
+ ({ id }) => id === "FXA_ACCOUNTS_BADGE"
+ );
+
+ Assert.ok(msg, "FxA test message exists");
+
+ // Ensure we badge immediately
+ msg.content.delay = undefined;
+
+ let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ // Click the button and clear the badge that occurs normally at startup
+ let fxaButton = browserWindow.document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Initially element is not badged"
+ );
+
+ ToolbarBadgeHub.registerBadgeNotificationListener(msg);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ await BrowserTestUtils.closeWindow(newWin);
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ // Click the button and clear the badge
+ fxaButton = document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Button should no longer be badged"
+ );
+});
+
+add_task(async function test_fxa_badge_shown_withdelay() {
+ const [msg] = (await OnboardingMessageProvider.getMessages()).filter(
+ ({ id }) => id === "FXA_ACCOUNTS_BADGE"
+ );
+
+ Assert.ok(msg, "FxA test message exists");
+
+ // Enough to trigger the setTimeout badging
+ msg.content.delay = 1;
+
+ let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ // Click the button and clear the badge that occurs normally at startup
+ let fxaButton = browserWindow.document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Initially element is not badged"
+ );
+
+ ToolbarBadgeHub.registerBadgeNotificationListener(msg);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ await BrowserTestUtils.closeWindow(newWin);
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ // Click the button and clear the badge
+ fxaButton = document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Button should no longer be badged"
+ );
+});
diff --git a/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js
new file mode 100644
index 0000000000..b7169d6be7
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js
@@ -0,0 +1,1122 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+const { DefaultBrowserCheck } = ChromeUtils.importESModule(
+ "resource:///modules/BrowserGlue.sys.mjs"
+);
+
+const PDF_TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/file_pdf.PDF";
+
+async function openURLInWindow(window, url) {
+ const { selectedBrowser } = window.gBrowser;
+ BrowserTestUtils.startLoadingURIString(selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(selectedBrowser, false, url);
+ return selectedBrowser;
+}
+
+async function openURLInNewTab(window, ...args) {
+ return BrowserTestUtils.openNewForegroundTab(window.gBrowser, ...args);
+}
+
+const pdfMatch = sinon.match(val => {
+ return (
+ val?.id === "pdfJsFeatureCalloutCheck" && val?.context?.source === "open"
+ );
+});
+
+const validateCalloutCustomPosition = (element, absolutePosition, doc) => {
+ const browserBox = doc.querySelector("hbox#browser");
+ for (let position in absolutePosition) {
+ if (Object.prototype.hasOwnProperty.call(absolutePosition, position)) {
+ // remove the `px` at the end of our absolute position strings
+ const relativePos = parseFloat(absolutePosition[position]);
+ const elPos = element.getBoundingClientRect()[position];
+ const browserPos = browserBox.getBoundingClientRect()[position];
+
+ if (position in ["top", "left"]) {
+ if (elPos !== browserPos + relativePos) {
+ return false;
+ }
+ } else if (position in ["right", "bottom"]) {
+ if (elPos !== browserPos - relativePos) {
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+};
+
+const validateCalloutRTLPosition = (element, absolutePosition) => {
+ for (let position in absolutePosition) {
+ if (Object.prototype.hasOwnProperty.call(absolutePosition, position)) {
+ const pixels = parseFloat(absolutePosition[position]);
+ if (position === "left") {
+ if (element.getBoundingClientRect().right !== pixels) {
+ return false;
+ }
+ } else if (position === "right") {
+ if (element.getBoundingClientRect().left !== pixels) {
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+};
+
+const testMessage = {
+ message: {
+ id: "TEST_MESSAGE",
+ template: "feature_callout",
+ content: {
+ id: "TEST_MESSAGE",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ screens: [
+ {
+ id: "TEST_MESSAGE_1",
+ anchors: [
+ { selector: "#PanelUI-menu-button", arrow_position: "top-end" },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ raw: "Test title",
+ },
+ subtitle: {
+ raw: "Test subtitle",
+ },
+ primary_button: {
+ label: {
+ raw: "Done",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ priority: 1,
+ targeting: "true",
+ trigger: { id: "pdfJsFeatureCalloutCheck" },
+ },
+};
+
+const newtabTestMessage = {
+ id: "TEST_MESSAGE",
+ template: "feature_callout",
+ content: {
+ id: "TEST_MESSAGE",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ tour_pref_name: "browser.newtab.feature-tour",
+ tour_pref_default_value: JSON.stringify({
+ screen: "TEST_MESSAGE_1",
+ complete: false,
+ }),
+ screens: [
+ {
+ id: "TEST_MESSAGE_1",
+ anchors: [
+ {
+ selector: "hbox#browser",
+ arrow_position: "top-end",
+ absolute_position: { top: "45px", right: "55px" },
+ },
+ ],
+ content: {
+ position: "callout",
+ title: "Test callout title",
+ subtitle: "Test callout subtitle",
+ primary_button: {
+ label: "Test callout button",
+ },
+ },
+ },
+ ],
+ },
+ priority: 1,
+ targeting: "true",
+ trigger: { id: "newtabFeatureCalloutCheck" },
+};
+
+const testMessageScreenId = testMessage.message.content.screens[0].id;
+const newtabTestMessageScreenId = newtabTestMessage.content.screens[0].id;
+
+const inChaosMode = !!parseInt(Services.env.get("MOZ_CHAOSMODE"), 16);
+
+add_setup(async function () {
+ let timeoutFactor = 3;
+ // Runtime increases in chaos mode on Mac.
+ if (inChaosMode && AppConstants.platform === "macosx") {
+ timeoutFactor = 5;
+ }
+ requestLongerTimeout(timeoutFactor);
+});
+
+// Test that a feature callout message can be loaded into ASRouter and displayed
+// via a standard trigger. Also test that the callout can be a feature tour,
+// even if its tour pref doesn't exist in Firefox. The tour pref will be created
+// and cleaned up automatically. This allows a feature callout to be implemented
+// entirely off-train in an experiment, without landing anything in tree.
+add_task(async function triggered_feature_tour_with_custom_pref() {
+ let sandbox = sinon.createSandbox();
+ const TEST_MESSAGES = [
+ {
+ id: "TEST_FEATURE_TOUR",
+ template: "feature_callout",
+ content: {
+ id: "TEST_FEATURE_TOUR",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ tour_pref_name: "messaging-system-action.browser.test.feature-tour",
+ tour_pref_default_value: JSON.stringify({
+ screen: "FEATURE_CALLOUT_1",
+ complete: false,
+ }),
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1",
+ anchors: [
+ {
+ selector: "#PanelUI-menu-button",
+ arrow_position: "top-center-arrow-end",
+ },
+ ],
+ content: {
+ position: "callout",
+ title: { string_id: "callout-pdfjs-edit-title" },
+ subtitle: { string_id: "callout-pdfjs-edit-body-b" },
+ primary_button: {
+ label: { string_id: "callout-pdfjs-edit-button" },
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "messaging-system-action.browser.test.feature-tour",
+ value: JSON.stringify({
+ screen: "FEATURE_CALLOUT_2",
+ complete: false,
+ }),
+ },
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "BLOCK_MESSAGE",
+ data: { id: "TEST_FEATURE_TOUR" },
+ },
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "messaging-system-action.browser.test.feature-tour",
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ {
+ id: "FEATURE_CALLOUT_2",
+ anchors: [
+ {
+ selector: "#back-button",
+ arrow_position: "top-center-arrow-start",
+ },
+ ],
+ content: {
+ position: "callout",
+ title: { string_id: "callout-pdfjs-draw-title" },
+ subtitle: { string_id: "callout-pdfjs-draw-body-b" },
+ primary_button: {
+ label: { string_id: "callout-pdfjs-draw-button" },
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "BLOCK_MESSAGE",
+ data: { id: "TEST_FEATURE_TOUR" },
+ },
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "messaging-system-action.browser.test.feature-tour",
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "BLOCK_MESSAGE",
+ data: { id: "TEST_FEATURE_TOUR" },
+ },
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "messaging-system-action.browser.test.feature-tour",
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ priority: 2,
+ targeting: `(('messaging-system-action.browser.test.feature-tour' | preferenceValue) ? (('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')) ? ('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')[1] != "true") : true) : true)`,
+ trigger: { id: "nthTabClosed" },
+ },
+ {
+ id: "TEST_FEATURE_TOUR_2",
+ template: "feature_callout",
+ content: {
+ id: "TEST_FEATURE_TOUR_2",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_TEST",
+ anchors: [
+ {
+ selector: "#PanelUI-menu-button",
+ arrow_position: "top-center-arrow-end",
+ },
+ ],
+ content: {
+ position: "callout",
+ title: { string_id: "callout-pdfjs-edit-title" },
+ subtitle: { string_id: "callout-pdfjs-edit-body-b" },
+ primary_button: {
+ label: { string_id: "callout-pdfjs-edit-button" },
+ action: { dismiss: true },
+ },
+ },
+ },
+ ],
+ },
+ priority: 1,
+ targeting: "true",
+ trigger: { id: "nthTabClosed" },
+ },
+ ];
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+
+ // Test that callout is triggered and shown in browser chrome
+ const win1 = await BrowserTestUtils.openNewBrowserWindow();
+ win1.focus();
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser);
+ await TestUtils.waitForTick();
+ win1.gBrowser.removeTab(tab1);
+ await waitForCalloutScreen(
+ win1.document,
+ TEST_MESSAGES[0].content.screens[0].id
+ );
+ ok(
+ win1.document.querySelector(calloutSelector),
+ "Feature Callout is rendered in the browser chrome when a message is available"
+ );
+
+ // Test that a callout does NOT appear if another is already shown in any window.
+ const showFeatureCalloutSpy = sandbox.spy(
+ FeatureCalloutBroker,
+ "showFeatureCallout"
+ );
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ win2.focus();
+ const tab2 = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser);
+ await TestUtils.waitForTick();
+ win2.gBrowser.removeTab(tab2);
+ await BrowserTestUtils.waitForCondition(async () => {
+ const rvs = await Promise.all(showFeatureCalloutSpy.returnValues);
+ return (
+ showFeatureCalloutSpy.calledWith(
+ win2.gBrowser.selectedBrowser,
+ sinon.match(TEST_MESSAGES[0])
+ ) && rvs.every(rv => !rv)
+ );
+ }, "Waiting for showFeatureCallout to be called");
+ ok(
+ !win2.document.querySelector(calloutSelector),
+ "Feature Callout is not rendered when a callout is already shown in any window"
+ );
+ await BrowserTestUtils.closeWindow(win2);
+ win1.focus();
+ await BrowserTestUtils.waitForCondition(
+ async () => Services.focus.activeWindow === win1,
+ "Waiting for window 1 to be active"
+ );
+
+ // Test that the tour pref doesn't exist yet
+ ok(
+ !Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name),
+ "Tour pref does not exist yet"
+ );
+
+ // Test that the callout advances screen and sets the tour pref
+ win1.document.querySelector(calloutCTASelector).click();
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name),
+ "Waiting for tour pref to be set"
+ );
+ SimpleTest.isDeeply(
+ JSON.parse(
+ Services.prefs.getStringPref(
+ TEST_MESSAGES[0].content.tour_pref_name,
+ "{}"
+ )
+ ),
+ { screen: "FEATURE_CALLOUT_2", complete: false },
+ "Tour pref is set correctly"
+ );
+ await waitForCalloutScreen(
+ win1.document,
+ TEST_MESSAGES[0].content.screens[1].id
+ );
+ ok(
+ win1.document.querySelector(calloutSelector),
+ "Feature Callout screen 2 is rendered"
+ );
+
+ // Test that the callout is dismissed and cleans up the tour pref
+ win1.document.querySelector(calloutCTASelector).click();
+ await waitForCalloutRemoved(win1.document);
+ ok(
+ !win1.document.querySelector(calloutSelector),
+ "Feature Callout is not rendered after being dismissed"
+ );
+ ok(
+ !Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name),
+ "Tour pref is cleaned up correctly"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => !FeatureCalloutBroker.isCalloutShowing,
+ "Waiting for all callouts to empty from the callout broker"
+ );
+
+ // Test that the message was blocked so a different callout is shown
+ const tab3 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser);
+ await TestUtils.waitForTick();
+ win1.gBrowser.removeTab(tab3);
+ await waitForCalloutScreen(
+ win1.document,
+ TEST_MESSAGES[1].content.screens[0].id
+ );
+ ok(
+ win1.document.querySelector(calloutSelector),
+ "A different Feature Callout is rendered"
+ );
+ win1.document.querySelector(calloutCTASelector).click();
+ await waitForCalloutRemoved(win1.document);
+ ok(!FeatureCalloutBroker.isCalloutShowing, "No Feature Callout is shown");
+
+ BrowserTestUtils.closeWindow(win1);
+
+ sandbox.restore();
+ await ASRouter.unblockMessageById(TEST_MESSAGES[0].id);
+ await ASRouter.resetMessageState();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+});
+
+add_task(async function callout_not_shown_if_dialog_open() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(null, undefined, {
+ callback: async dialogWin => {
+ let rv = await FeatureCalloutBroker.showFeatureCallout(
+ win.gBrowser.selectedBrowser,
+ testMessage.message
+ );
+ ok(
+ !rv,
+ "Feature callout not shown when a dialog is open in the same window"
+ );
+ dialogWin.document.querySelector("dialog").getButton("cancel").click();
+ },
+ isSubDialog: true,
+ });
+ DefaultBrowserCheck.prompt(win);
+ await dialogPromise;
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function callout_not_shown_if_panel_open() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const gCUITestUtils = new CustomizableUITestUtils(win);
+ await gCUITestUtils.openMainMenu();
+
+ let rv = await FeatureCalloutBroker.showFeatureCallout(
+ win.gBrowser.selectedBrowser,
+ testMessage.message
+ );
+ ok(!rv, "Feature callout not shown when a panel is open in the same window");
+
+ await gCUITestUtils.hideMainMenu();
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function feature_callout_renders_in_browser_chrome_for_pdf() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ const container = doc.querySelector(calloutSelector);
+ ok(
+ container,
+ "Feature Callout is rendered in the browser chrome with a new window when a message is available"
+ );
+
+ // click CTA to close
+ doc.querySelector(calloutCTASelector).click();
+ await waitForCalloutRemoved(doc);
+ ok(
+ true,
+ "Feature callout removed from browser chrome after clicking button configured to navigate"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
+
+add_task(
+ async function feature_callout_renders_and_hides_in_chrome_when_switching_tabs() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ PDF_TEST_URL
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ ok(
+ doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout rendered when opening a new tab with PDF url"
+ );
+
+ const tab2 = await openURLInNewTab(win, "about:preferences");
+ tab2.focus();
+ await BrowserTestUtils.waitForCondition(() => {
+ return !doc.body.querySelector(
+ "#multi-stage-message-root.featureCallout"
+ );
+ });
+
+ ok(
+ !doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout removed when tab without PDF URL is navigated to"
+ );
+
+ const tab3 = await openURLInNewTab(win, PDF_TEST_URL);
+ tab3.focus();
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ ok(
+ doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout still renders when opening a new tab with PDF url after being initially rendered on another tab"
+ );
+
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ ok(
+ doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout rendered on original tab after switching tabs multiple times"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_disappears_when_navigating_to_non_pdf_url_in_same_tab() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ PDF_TEST_URL
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ ok(
+ doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout rendered when opening a new tab with PDF url"
+ );
+
+ BrowserTestUtils.startLoadingURIString(win.gBrowser, "about:preferences");
+ await BrowserTestUtils.waitForLocationChange(
+ win.gBrowser,
+ "about:preferences"
+ );
+ await waitForCalloutRemoved(doc);
+
+ ok(
+ !doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout not rendered on original tab after navigating to non pdf URL"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_disappears_when_closing_foreground_pdf_tab() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ PDF_TEST_URL
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ ok(
+ doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout rendered when opening a new tab with PDF url"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ await waitForCalloutRemoved(doc);
+
+ ok(
+ !doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout disappears after closing foreground tab"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_does_not_appear_when_opening_background_pdf_tab() {
+ const sandbox = sinon.createSandbox();
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ const TEST_MESSAGES = [newtabTestMessage];
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const doc = win.document;
+
+ const tab1 = await BrowserTestUtils.addTab(win.gBrowser, PDF_TEST_URL);
+ ok(
+ !doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout not rendered when opening a background tab with PDF url"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+
+ ok(
+ !doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout still not rendered after closing background tab with PDF url"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ }
+);
+
+add_task(
+ async function newtab_feature_callout_appears_in_browser_chrome_on_newtab() {
+ const sandbox = sinon.createSandbox();
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ const TEST_MESSAGES = [newtabTestMessage];
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "about:newtab"
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, newtabTestMessageScreenId);
+ ok(
+ doc.querySelector(`.${newtabTestMessageScreenId}`),
+ "Newtab feature callout rendered when opening a focused newtab"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ await waitForCalloutRemoved(doc);
+ ok(
+ !doc.querySelector(`.${newtabTestMessageScreenId}`),
+ "Feature callout disappears after closing new tab"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ }
+);
+
+add_task(
+ async function newtab_feature_callout_does_not_appear_when_opening_background_newtab_tab() {
+ const sandbox = sinon.createSandbox();
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ const TEST_MESSAGES = [newtabTestMessage];
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const doc = win.document;
+
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "about:preferences"
+ );
+ const tab2 = await BrowserTestUtils.addTab(win.gBrowser, "about:newtab");
+ ok(
+ !doc.querySelector(`.${newtabTestMessageScreenId}`),
+ "Newtab feature callout not rendered when opening a background newtab"
+ );
+
+ BrowserTestUtils.removeTab(tab2);
+ await waitForCalloutRemoved(doc);
+ ok(
+ !doc.querySelector(`.${newtabTestMessageScreenId}`),
+ "Feature callout still not rendered after closing background tab"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ }
+);
+
+add_task(
+ async function newtab_feature_callout_does_not_appear_in_browser_chrome_on_new_window() {
+ const sandbox = sinon.createSandbox();
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ const TEST_MESSAGES = [newtabTestMessage];
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, "about:newtab");
+ const doc = win.document;
+
+ await waitForCalloutScreen(doc, newtabTestMessageScreenId);
+ ok(
+ doc.querySelector(`.${newtabTestMessageScreenId}`),
+ "Newtab Feature Callout is in the browser chrome of first window when a message is available"
+ );
+
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win2, "about:newtab");
+ const doc2 = win2.document;
+ ok(
+ !doc2.querySelector(`.${newtabTestMessageScreenId}`),
+ "Newtab Feature Callout is not in the browser chrome new window when a message is available"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await BrowserTestUtils.closeWindow(win2);
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ }
+);
+
+add_task(
+ async function feature_callout_disappears_when_navigating_from_newtab_to_pdf_url_in_same_tab() {
+ const sandbox = sinon.createSandbox();
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ const TEST_MESSAGES = [newtabTestMessage];
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "about:newtab"
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, newtabTestMessageScreenId);
+ ok(
+ doc.querySelector(`.${newtabTestMessageScreenId}`),
+ "Feature callout rendered when opening a newtab"
+ );
+
+ BrowserTestUtils.startLoadingURIString(win.gBrowser, PDF_TEST_URL);
+ await BrowserTestUtils.waitForLocationChange(win.gBrowser, PDF_TEST_URL);
+ await waitForCalloutRemoved(doc);
+
+ ok(
+ !doc.querySelector(`.${testMessageScreenId}`),
+ "Feature callout not rendered on original tab after navigating to PDF"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ }
+);
+
+add_task(
+ async function feature_callout_disappears_when_navigating_from_newtab_to_pdf_url_in_different_tab() {
+ const sandbox = sinon.createSandbox();
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ const TEST_MESSAGES = [newtabTestMessage];
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "about:newtab"
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, newtabTestMessageScreenId);
+ ok(
+ doc.querySelector(`.${newtabTestMessageScreenId}`),
+ "Feature callout rendered when opening a newtab"
+ );
+
+ const tab2 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ PDF_TEST_URL
+ );
+ tab2.focus();
+ await waitForCalloutRemoved(doc);
+
+ ok(
+ !doc.querySelector(`.${testMessageScreenId}`),
+ "Newtab feature callout not rendered after navigating to PDF"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ }
+);
+
+add_task(
+ async function feature_callout_is_positioned_relative_to_browser_window() {
+ // Deep copying our test message so we can alter it without disrupting future tests
+ const pdfTestMessage = JSON.parse(JSON.stringify(testMessage));
+ const pdfTestMessageCalloutSelector =
+ pdfTestMessage.message.content.screens[0].id;
+
+ pdfTestMessage.message.content.screens[0].anchors[0] = {
+ selector: "hbox#browser",
+ absolute_position: { top: "45px", right: "25px" },
+ };
+
+ const sandbox = sinon.createSandbox();
+ const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
+ const TEST_MESSAGES = [pdfTestMessage.message];
+ getMessagesStub.returns(TEST_MESSAGES);
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, pdfTestMessageCalloutSelector);
+
+ // Verify that callout renders in appropriate position (without infobar element)
+ const callout = doc.querySelector(`.${pdfTestMessageCalloutSelector}`);
+ ok(callout, "Callout is rendered when navigating to PDF file");
+ ok(
+ validateCalloutCustomPosition(
+ callout,
+ pdfTestMessage.message.content.screens[0].anchors[0].absolute_position,
+ doc
+ ),
+ "Callout custom position is as expected"
+ );
+
+ // Add height to the top of the browser to simulate an infobar or other element
+ const navigatorToolBox = doc.querySelector("#navigator-toolbox");
+ navigatorToolBox.style.height = "150px";
+ // We test in a new tab because the callout does not adjust itself
+ // when size of the navigator-toolbox-background box changes.
+ const tab = await openURLInNewTab(win, "https://example.com/some2.pdf");
+ // Verify that callout renders in appropriate position (with infobar element displayed)
+ ok(
+ validateCalloutCustomPosition(
+ callout,
+ pdfTestMessage.message.content.screens[0].anchors[0].absolute_position,
+ doc
+ ),
+ "Callout custom position is as expected while navigator toolbox height is extended"
+ );
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ }
+);
+
+add_task(
+ async function custom_position_callout_is_horizontally_reversed_in_rtl_layouts() {
+ // Deep copying our test message so we can alter it without disrupting future tests
+ const pdfTestMessage = JSON.parse(JSON.stringify(testMessage));
+ const pdfTestMessageCalloutSelector =
+ pdfTestMessage.message.content.screens[0].id;
+
+ pdfTestMessage.message.content.screens[0].anchors[0] = {
+ selector: "hbox#browser",
+ absolute_position: { top: "45px", right: "25px" },
+ };
+
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(pdfTestMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ win.document.dir = "rtl";
+ Assert.strictEqual(
+ win.document.documentElement.getAttribute("dir"),
+ "rtl",
+ "browser window is in RTL"
+ );
+
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, pdfTestMessageCalloutSelector);
+
+ const callout = doc.querySelector(`.${pdfTestMessageCalloutSelector}`);
+ ok(callout, "Callout is rendered when navigating to PDF file");
+ ok(
+ validateCalloutRTLPosition(
+ callout,
+ pdfTestMessage.message.content.screens[0].anchors[0].absolute_position
+ ),
+ "Callout custom position is rendered appropriately in RTL mode"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(async function feature_callout_dismissed_on_escape() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ const container = doc.querySelector(calloutSelector);
+ ok(
+ container,
+ "Feature Callout is rendered in the browser chrome with a new window when a message is available"
+ );
+
+ // Ensure the browser is focused
+ win.gBrowser.selectedBrowser.focus();
+
+ // Press Escape to close
+ EventUtils.synthesizeKey("KEY_Escape", {}, win);
+ await waitForCalloutRemoved(doc);
+ ok(true, "Feature callout dismissed after pressing Escape");
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
+
+add_task(
+ async function feature_callout_not_dismissed_on_escape_with_interactive_elm_focused() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, testMessageScreenId);
+ const container = doc.querySelector(calloutSelector);
+ ok(
+ container,
+ "Feature Callout is rendered in the browser chrome with a new window when a message is available"
+ );
+
+ // Ensure an interactive element is focused
+ win.gURLBar.focus();
+
+ // Press Escape to close
+ EventUtils.synthesizeKey("KEY_Escape", {}, win);
+ await TestUtils.waitForTick();
+ // Wait 500ms for transition to complete
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ ok(
+ doc.querySelector(calloutSelector),
+ "Feature callout is not dismissed after pressing Escape because an interactive element is focused"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(async function first_anchor_selected_is_valid() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const config = {
+ win,
+ location: "chrome",
+ context: "chrome",
+ browser: win.gBrowser.selectedBrowser,
+ theme: { preset: "chrome" },
+ };
+
+ const message = JSON.parse(JSON.stringify(testMessage.message));
+ const sandbox = sinon.createSandbox();
+
+ const doc = win.document;
+ const featureCallout = new FeatureCallout(config);
+ const getAnchorSpy = sandbox.spy(featureCallout, "_getAnchor");
+ featureCallout.showFeatureCallout(message);
+ await waitForCalloutScreen(doc, message.content.screens[0].id);
+ ok(
+ getAnchorSpy.alwaysReturned(
+ sandbox.match(message.content.screens[0].anchors[0])
+ ),
+ "The first anchor is selected"
+ );
+
+ win.document.querySelector(calloutCTASelector).click();
+ await waitForCalloutRemoved(win.document);
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
+
+add_task(async function first_anchor_selected_is_invalid() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const doc = win.document;
+ const config = {
+ win,
+ location: "chrome",
+ context: "chrome",
+ browser: win.gBrowser.selectedBrowser,
+ theme: { preset: "chrome" },
+ };
+
+ let stopReloadButton = doc.getElementById("stop-reload-button");
+
+ await gCustomizeMode.addToPanel(stopReloadButton);
+
+ const message = JSON.parse(JSON.stringify(testMessage.message));
+ message.content.screens[0].anchors = [
+ // element that does not exist
+ { selector: "#some-fake-id.some-fake-class", arrow_position: "top" },
+ // element that exists but has no height/width
+ { selector: "#a11y-announcement", arrow_position: "top" },
+ // element that exists but is hidden by CSS
+ { selector: "#window-modal-dialog", arrow_position: "top" },
+ // customizable widget that's in the overflow panel
+ { selector: "#stop-reload-button", arrow_position: "top" },
+ // element that is fully visible
+ { selector: "#PanelUI-menu-button", arrow_position: "top" },
+ ];
+ const sandbox = sinon.createSandbox();
+
+ const featureCallout = new FeatureCallout(config);
+ const getAnchorSpy = sandbox.spy(featureCallout, "_getAnchor");
+ featureCallout.showFeatureCallout(message);
+ await waitForCalloutScreen(doc, message.content.screens[0].id);
+ is(
+ getAnchorSpy.lastCall.returnValue.selector,
+ message.content.screens[0].anchors[4].selector,
+ "The first valid anchor (anchor 5) is selected"
+ );
+
+ win.document.querySelector(calloutCTASelector).click();
+ await waitForCalloutRemoved(win.document);
+ CustomizableUI.reset();
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
diff --git a/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js
new file mode 100644
index 0000000000..1f87c71ec7
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js
@@ -0,0 +1,430 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function getTestMessage() {
+ return {
+ id: "TEST_PANEL_FEATURE_CALLOUT",
+ template: "feature_callout",
+ groups: [],
+ content: {
+ id: "TEST_PANEL_FEATURE_CALLOUT",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "TEST_PANEL_FEATURE_CALLOUT",
+ anchors: [
+ {
+ selector: "#PanelUI-menu-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ },
+ ],
+ content: {
+ position: "callout",
+ title: { raw: "Panel Feature Callout" },
+ dismiss_button: {
+ action: { dismiss: true },
+ },
+ },
+ },
+ ],
+ },
+ };
+}
+
+/**
+ * Set up a callout and show it.
+ *
+ * @param {MozBrowser} browser Probably the selected browser in the top window.
+ * @param {object} message The message to show.
+ * @returns {Promise<{featureCallout: FeatureCallout, showing: boolean, closed: Promise}>}
+ * A promise that resolves to an object containing the FeatureCallout
+ * instance, a boolean for whether the callout started showing correctly, and
+ * a promise that resolves when the callout is closed.
+ */
+async function showFeatureCallout(browser, message) {
+ let resolveClosed;
+ let closed = new Promise(resolve => {
+ resolveClosed = resolve;
+ });
+ const config = {
+ win: browser.ownerGlobal,
+ location: "chrome",
+ context: "chrome",
+ browser,
+ theme: { preset: "chrome" },
+ listener: (_, event) => {
+ if (event === "end") {
+ resolveClosed();
+ }
+ },
+ };
+ const featureCallout = new FeatureCallout(config);
+ let showing = await featureCallout.showFeatureCallout(message);
+ return { featureCallout, showing, closed };
+}
+
+/**
+ * Make a new window, open a feature callout in it, run a function to hide the
+ * callout, and assert that the callout is hidden correctly. Optionally run a
+ * function after the callout is closed, for additional assertions. Finally,
+ * close the window.
+ *
+ * @param {function(Window, Element, FeatureCallout)} hideFn A function that
+ * hides the callout. Passed the following params: window, callout container,
+ * and FeatureCallout instance.
+ * @param {function(Window, Element, FeatureCallout)} afterCloseFn An optional
+ * function to run after the callout is closed. Same params as hideFn.
+ * @param {object} message The message to show.
+ */
+async function testCalloutHiddenIf(
+ hideFn,
+ afterCloseFn,
+ message = getTestMessage()
+) {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ win.focus();
+ const doc = win.document;
+ const browser = win.gBrowser.selectedBrowser;
+ const { featureCallout, showing, closed } = await showFeatureCallout(
+ browser,
+ message
+ );
+
+ await waitForCalloutScreen(doc, message.content.screens[0].id);
+ let calloutContainer = featureCallout._container;
+ ok(showing && calloutContainer, "Feature callout should be showing");
+
+ await hideFn(win, calloutContainer, featureCallout);
+
+ await closed;
+ await waitForCalloutRemoved(doc);
+ ok(!doc.querySelector(calloutSelector), "Feature callout should be hidden");
+
+ await afterCloseFn?.(win, calloutContainer, featureCallout);
+ await BrowserTestUtils.closeWindow(win);
+}
+
+// Test that the callout is correctly created as a panel and positioned.
+add_task(async function panel_feature_callout() {
+ await testCalloutHiddenIf(async (win, calloutContainer) => {
+ is(calloutContainer.localName, "panel", "Callout container is a panel");
+ await BrowserTestUtils.waitForMutationCondition(
+ calloutContainer,
+ { attributeFilter: ["arrow-position"] },
+ () => calloutContainer.getAttribute("arrow-position") === "top-end"
+ );
+ is(
+ calloutContainer.anchorNode.id,
+ "PanelUI-menu-button",
+ "Callout container is anchored to the app menu button"
+ );
+ is(
+ calloutContainer.getAttribute("arrow-position"),
+ "top-end",
+ "Callout container arrow is positioned correctly"
+ );
+
+ win.document.querySelector(calloutDismissSelector).click();
+ });
+});
+
+// Test that the callout is hidden if another popup is shown.
+add_task(async function panel_feature_callout_hidden_on_popupshowing() {
+ await testCalloutHiddenIf(async win => {
+ // Click the app menu button to open the panel.
+ win.document.querySelector("#PanelUI-menu-button").click();
+ });
+});
+
+// Test that the callout is hidden if its anchor node is hidden.
+add_task(async function panel_feature_callout_hidden_on_anchor_hidden() {
+ await testCalloutHiddenIf(async win => {
+ // Hide the app menu button.
+ win.document.querySelector("#PanelUI-menu-button").hidden = true;
+ });
+});
+
+// Panels automatically track the movement of their anchor nodes, so test that
+// the callout moves with its anchor node.
+add_task(async function panel_feature_callout_follows_anchor() {
+ await testCalloutHiddenIf(async (win, calloutContainer) => {
+ let startingX = calloutContainer.getBoundingClientRect().x;
+
+ // Move the app menu button away from the right edge of the window.
+ calloutContainer.anchorNode.style.marginInlineEnd = "100px";
+
+ // Wait for the callout to reposition itself.
+ await BrowserTestUtils.waitForCondition(
+ () => calloutContainer.getBoundingClientRect().x !== startingX,
+ "Callout should reposition itself"
+ );
+
+ win.document.querySelector(calloutDismissSelector).click();
+ });
+});
+
+// Panels normally set the `[open]` attribute on their anchor node when they're
+// open, so that the anchor node can be styled differently when the panel is
+// open. Not every anchor node has styles for this, but e.g. chrome buttons do.
+add_task(async function panel_feature_callout_anchor_open_attr() {
+ let anchor;
+ await testCalloutHiddenIf(
+ async (win, calloutContainer) => {
+ anchor = calloutContainer.anchorNode;
+ ok(
+ anchor.hasAttribute("open"),
+ "Callout container's anchor node should have its [open] attribute set"
+ );
+
+ win.document.querySelector(calloutDismissSelector).click();
+ },
+ (win, calloutContainer) => {
+ ok(
+ !anchor.hasAttribute("open"),
+ "Callout container's anchor node should not have its [open] attribute set"
+ );
+ }
+ );
+});
+
+// However, some panels don't want to set the `[open]` attribute on their anchor
+// node. Sometimes the panel is more of a hint than a menu, and the `[open]`
+// style could give the impression that it's a menu. Or the anchor might already
+// have its `[open]` attribute set by something else, and we may not want to
+// interfere with that. So this feature is configurable by adding the
+// no_open_on_anchor property to the anchor.
+add_task(async function panel_feature_callout_no_anchor_open_attr() {
+ let message = getTestMessage();
+ message.content.screens[0].anchors[0].no_open_on_anchor = true;
+ await testCalloutHiddenIf(
+ async (win, calloutContainer) => {
+ let anchor = calloutContainer.anchorNode;
+ ok(
+ !anchor.hasAttribute("open"),
+ "Callout container's anchor node should not have its [open] attribute set"
+ );
+
+ win.document.querySelector(calloutDismissSelector).click();
+ },
+ null,
+ message
+ );
+});
+
+add_task(async function feature_callout_split_dismiss_button() {
+ let message = getTestMessage();
+ message.content.screens[0].content.secondary_button = {
+ label: { raw: "Advance" },
+ action: { navigate: true },
+ };
+ message.content.screens[0].content.submenu_button = {
+ submenu: [
+ {
+ type: "action",
+ label: { raw: "Item 1" },
+ action: { navigate: true },
+ id: "item1",
+ },
+ {
+ type: "action",
+ label: { raw: "Item 2" },
+ action: { navigate: true },
+ id: "item2",
+ },
+ {
+ type: "menu",
+ label: { raw: "Menu 1" },
+ submenu: [
+ {
+ type: "action",
+ label: { raw: "Item 3" },
+ action: { navigate: true },
+ id: "item3",
+ },
+ {
+ type: "action",
+ label: { raw: "Item 4" },
+ action: { navigate: true },
+ id: "item4",
+ },
+ ],
+ id: "menu1",
+ },
+ ],
+ attached_to: "secondary_button",
+ };
+
+ await testCalloutHiddenIf(
+ async (win, calloutContainer) => {
+ let splitButtonContainer = calloutContainer.querySelector(
+ `#${calloutId} .split-button-container`
+ );
+ let secondaryButton = calloutContainer.querySelector(
+ `#${calloutId} .secondary:not(.submenu-button)`
+ );
+ let submenuButton = calloutContainer.querySelector(
+ `#${calloutId} .submenu-button`
+ );
+ let submenu = calloutContainer.querySelector(
+ `#${calloutId} .fxms-multi-stage-submenu`
+ );
+ ok(splitButtonContainer, "Callout should have a split button container");
+ ok(secondaryButton, "Callout should have a split secondary button");
+ ok(submenuButton, "Callout should have a split submenu button");
+ ok(submenu, "Callout should have a submenu");
+
+ // Click the submenu button and wait for the submenu (menupopup) to open.
+ let opened = BrowserTestUtils.waitForEvent(submenu, "popupshown");
+ submenuButton.click();
+ await opened;
+
+ // Assert that all the menu items are present and that the order and
+ // structure is correct.
+ async function recursiveTestMenuItems(items, popup) {
+ let children = [...popup.children];
+ for (let element of children) {
+ let index = children.indexOf(element);
+ let itemAtIndex = items[index];
+ switch (element.localName) {
+ case "menuitem":
+ is(
+ itemAtIndex.type,
+ "action",
+ `Menu item ${itemAtIndex.id} should be an action`
+ );
+ is(
+ JSON.stringify(element.config),
+ JSON.stringify(itemAtIndex),
+ `Menu item ${itemAtIndex.id} should have correct config`
+ );
+ is(
+ element.value,
+ itemAtIndex.id,
+ `Menu item ${itemAtIndex.id} should have correct value`
+ );
+ break;
+ case "menu":
+ is(
+ itemAtIndex.type,
+ "menu",
+ `Menu item ${itemAtIndex.id} should be a menu`
+ );
+ is(
+ element.value,
+ itemAtIndex.id,
+ `Menu item ${itemAtIndex.id} should have correct value`
+ );
+ info(`Testing submenu ${itemAtIndex.id}`);
+ await recursiveTestMenuItems(
+ itemAtIndex.submenu,
+ element.querySelector("menupopup")
+ );
+ break;
+ case "menuseparator":
+ is(
+ itemAtIndex.type,
+ "separator",
+ `Menu item ${index} should be a separator`
+ );
+ break;
+ default:
+ ok(false, "Child of unknown type in submenu");
+ }
+ }
+ }
+
+ info("Testing main menu");
+ await recursiveTestMenuItems(
+ message.content.screens[0].content.submenu_button.submenu,
+ submenu
+ );
+
+ submenu.querySelector(`menuitem[value="item1"]`).click();
+ },
+ null,
+ message
+ );
+});
+
+add_task(async function feature_callout_tab_order() {
+ let message = getTestMessage();
+ message.content.screens[0].content.secondary_button = {
+ label: { raw: "Dismiss" },
+ action: { dismiss: true },
+ };
+ message.content.screens[0].content.primary_button = {
+ label: { raw: "Advance" },
+ action: { navigate: true },
+ };
+
+ await testCalloutHiddenIf(
+ async (win, calloutContainer) => {
+ // Test that feature callout initially focuses the primary button.
+ let primaryButton = calloutContainer.querySelector(
+ `#${calloutId} .primary`
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => win.document.activeElement === primaryButton,
+ "Primary button should be focused"
+ );
+
+ // Test that pressing Tab loops through the primary button, secondary
+ // button, and dismiss button.
+ let secondaryButton = calloutContainer.querySelector(
+ `#${calloutId} .secondary`
+ );
+ let onFocused2 = BrowserTestUtils.waitForEvent(secondaryButton, "focus");
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ await onFocused2;
+ is(
+ win.document.activeElement,
+ secondaryButton,
+ "Secondary button should be focused"
+ );
+
+ let dismissButton = calloutContainer.querySelector(
+ `#${calloutId} .dismiss-button`
+ );
+ let onFocused3 = BrowserTestUtils.waitForEvent(dismissButton, "focus");
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ await onFocused3;
+ is(
+ win.document.activeElement,
+ dismissButton,
+ "Dismiss button should be focused"
+ );
+
+ let onFocused4 = BrowserTestUtils.waitForEvent(primaryButton, "focus");
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ await onFocused4;
+ is(
+ win.document.activeElement,
+ primaryButton,
+ "Primary button should be focused"
+ );
+
+ // Test that pressing Shift+Tab loops back to the dismiss button.
+ let onFocused5 = BrowserTestUtils.waitForEvent(dismissButton, "focus");
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, win);
+ await onFocused5;
+ is(
+ win.document.activeElement,
+ dismissButton,
+ "Dismiss button should be focused"
+ );
+
+ EventUtils.synthesizeKey("VK_SPACE", {}, win);
+ },
+
+ null,
+ message
+ );
+});
diff --git a/browser/components/asrouter/tests/browser/browser_trigger_listeners.js b/browser/components/asrouter/tests/browser/browser_trigger_listeners.js
new file mode 100644
index 0000000000..7c86645221
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/browser_trigger_listeners.js
@@ -0,0 +1,430 @@
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+const { ASRouterTriggerListeners } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs"
+);
+
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+const mockIdleService = {
+ _observers: new Set(),
+ _fireObservers(state) {
+ for (let observer of this._observers.values()) {
+ observer.observe(this, state, null);
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]),
+ idleTime: 1200000,
+ addIdleObserver(observer, time) {
+ this._observers.add(observer);
+ },
+ removeIdleObserver(observer, time) {
+ this._observers.delete(observer);
+ },
+};
+
+const sleepMs = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+
+const inChaosMode = !!parseInt(Services.env.get("MOZ_CHAOSMODE"), 16);
+
+async function waitForUrlLoad(url) {
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser, false, url);
+}
+
+add_setup(async function () {
+ // Runtime increases in chaos mode on Mac.
+ if (inChaosMode && AppConstants.platform === "macosx") {
+ requestLongerTimeout(2);
+ }
+
+ registerCleanupFunction(() => {
+ const trigger = ASRouterTriggerListeners.get("openURL");
+ trigger.uninit();
+ });
+});
+
+add_task(async function test_openURL_visit_counter() {
+ const trigger = ASRouterTriggerListeners.get("openURL");
+ const stub = sinon.stub();
+ trigger.uninit();
+
+ trigger.init(stub, ["example.com"]);
+
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("https://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+
+ Assert.equal(stub.callCount, 3, "Stub called 3 times for example.com host");
+ Assert.equal(
+ stub.firstCall.args[1].context.visitsCount,
+ 1,
+ "First call should have count 1"
+ );
+ Assert.equal(
+ stub.thirdCall.args[1].context.visitsCount,
+ 2,
+ "Third call should have count 2 for http://example.com"
+ );
+});
+
+add_task(async function test_openURL_visit_counter_withPattern() {
+ const trigger = ASRouterTriggerListeners.get("openURL");
+ const stub = sinon.stub();
+ trigger.uninit();
+
+ // Match any valid URL
+ trigger.init(stub, [], ["*://*/*"]);
+
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("https://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+
+ Assert.equal(stub.callCount, 3, "Stub called 3 times for example.com host");
+ Assert.equal(
+ stub.firstCall.args[1].context.visitsCount,
+ 1,
+ "First call should have count 1"
+ );
+ Assert.equal(
+ stub.thirdCall.args[1].context.visitsCount,
+ 2,
+ "Third call should have count 2 for http://example.com"
+ );
+});
+
+add_task(async function test_captivePortalLogin() {
+ const stub = sinon.stub();
+ const captivePortalTrigger =
+ ASRouterTriggerListeners.get("captivePortalLogin");
+
+ captivePortalTrigger.init(stub);
+
+ Services.obs.notifyObservers(this, "captive-portal-login-success", {});
+
+ Assert.ok(stub.called, "Called after login event");
+
+ captivePortalTrigger.uninit();
+
+ Services.obs.notifyObservers(this, "captive-portal-login-success", {});
+
+ Assert.equal(stub.callCount, 1, "Not called after uninit");
+});
+
+add_task(async function test_preferenceObserver() {
+ const stub = sinon.stub();
+ const poTrigger = ASRouterTriggerListeners.get("preferenceObserver");
+
+ poTrigger.uninit();
+
+ poTrigger.init(stub, ["foo.bar", "bar.foo"]);
+
+ Services.prefs.setStringPref("foo.bar", "foo.bar");
+
+ Assert.ok(stub.calledOnce, "Called for pref foo.bar");
+ Assert.deepEqual(
+ stub.firstCall.args[1],
+ {
+ id: "preferenceObserver",
+ param: { type: "foo.bar" },
+ },
+ "Called with expected arguments"
+ );
+
+ Services.prefs.setStringPref("bar.foo", "bar.foo");
+ Assert.ok(stub.calledTwice, "Called again for second pref.");
+ Services.prefs.clearUserPref("foo.bar");
+ Assert.ok(stub.calledThrice, "Called when clearing the pref as well.");
+
+ stub.resetHistory();
+ poTrigger.uninit();
+
+ Services.prefs.clearUserPref("bar.foo");
+ Assert.ok(stub.notCalled, "Not called after uninit");
+});
+
+add_task(async function test_nthTabClosed() {
+ const handlerStub = sinon.stub();
+ const tabClosedTrigger = ASRouterTriggerListeners.get("nthTabClosed");
+ tabClosedTrigger.uninit();
+ tabClosedTrigger.init(handlerStub);
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ BrowserTestUtils.removeTab(tab1);
+ Assert.ok(handlerStub.calledOnce, "Called once after first tab closed");
+
+ BrowserTestUtils.removeTab(tab2);
+ Assert.ok(handlerStub.calledTwice, "Called twice after second tab closed");
+
+ handlerStub.resetHistory();
+ tabClosedTrigger.uninit();
+
+ Assert.ok(handlerStub.notCalled, "Not called after uninit");
+});
+
+add_task(async function test_cookieBannerDetected() {
+ const handlerStub = sinon.stub();
+ const bannerDetectedTrigger = ASRouterTriggerListeners.get(
+ "cookieBannerDetected"
+ );
+ bannerDetectedTrigger.uninit();
+ bannerDetectedTrigger.init(handlerStub);
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ let eventWait = BrowserTestUtils.waitForEvent(win, "cookiebannerdetected");
+ win.dispatchEvent(new Event("cookiebannerdetected"));
+ await eventWait;
+ let closeWindow = BrowserTestUtils.closeWindow(win);
+
+ Assert.ok(
+ handlerStub.called,
+ "Called after `cookiebannerdetected` event fires"
+ );
+
+ handlerStub.resetHistory();
+ bannerDetectedTrigger.uninit();
+
+ Assert.ok(handlerStub.notCalled, "Not called after uninit");
+ await closeWindow;
+});
+
+add_task(async function test_cookieBannerHandled() {
+ const handlerStub = sinon.stub();
+ const bannerHandledTrigger = ASRouterTriggerListeners.get(
+ "cookieBannerHandled"
+ );
+ bannerHandledTrigger.uninit();
+ bannerHandledTrigger.init(handlerStub);
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ win.focus();
+ let eventWait = BrowserTestUtils.waitForEvent(win, "cookiebannerhandled");
+ win.windowUtils.dispatchEventToChromeOnly(
+ win,
+ new CustomEvent("cookiebannerhandled", {
+ bubbles: true,
+ cancelable: false,
+ detail: {
+ windowContext: {
+ rootFrameLoader: { ownerElement: win.gBrowser.selectedBrowser },
+ },
+ },
+ })
+ );
+ await eventWait;
+ let closeWindow = BrowserTestUtils.closeWindow(win);
+
+ Assert.ok(
+ handlerStub.called,
+ "Called after `cookiebannerhandled` event fires"
+ );
+
+ handlerStub.resetHistory();
+ bannerHandledTrigger.uninit();
+
+ Assert.ok(handlerStub.notCalled, "Not called after uninit");
+ await closeWindow;
+});
+
+function getIdleTriggerMock() {
+ const idleTrigger = ASRouterTriggerListeners.get("activityAfterIdle");
+ idleTrigger.uninit();
+ const sandbox = sinon.createSandbox();
+ const handlerStub = sandbox.stub();
+ sandbox.stub(idleTrigger, "_triggerDelay").value(0);
+ sandbox.stub(idleTrigger, "_wakeDelay").value(30);
+ sandbox.stub(idleTrigger, "_idleService").value(mockIdleService);
+ let restored = false;
+ const restore = () => {
+ if (restored) {
+ return;
+ }
+ restored = true;
+ idleTrigger.uninit();
+ sandbox.restore();
+ };
+ registerCleanupFunction(restore);
+ idleTrigger.init(handlerStub);
+ return { idleTrigger, handlerStub, restore };
+}
+
+// Test that the trigger fires under normal conditions.
+add_task(async function test_activityAfterIdle() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let firedOnActive = new Promise(resolve =>
+ handlerStub.callsFake(() => resolve(true))
+ );
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ ok(handlerStub.notCalled, "Not called when idle");
+ mockIdleService._fireObservers("active");
+ ok(await firedOnActive, "Called once when active after idle");
+ restore();
+});
+
+// Test that the trigger does not fire when the active window is private.
+add_task(async function test_activityAfterIdlePrivateWindow() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ ok(PrivateBrowsingUtils.isWindowPrivate(privateWin), "Window is private");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("active");
+ await TestUtils.waitForTick();
+ ok(handlerStub.notCalled, "Not called when active window is private");
+ await BrowserTestUtils.closeWindow(privateWin);
+ restore();
+});
+
+// Test that the trigger does not fire when the window is minimized, but does
+// fire after the window is restored.
+add_task(async function test_activityAfterIdleHiddenWindow() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let firedOnRestore = new Promise(resolve =>
+ handlerStub.callsFake(() => resolve(true))
+ );
+ window.minimize();
+ await BrowserTestUtils.waitForCondition(
+ () => window.windowState === window.STATE_MINIMIZED,
+ "Window should be minimized"
+ );
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("active");
+ await TestUtils.waitForTick();
+ ok(handlerStub.notCalled, "Not called when window is minimized");
+ window.restore();
+ ok(await firedOnRestore, "Called once after restoring minimized window");
+ restore();
+});
+
+// Test that the trigger does not fire immediately after waking from sleep.
+add_task(async function test_activityAfterIdleWake() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let firedAfterWake = new Promise(resolve =>
+ handlerStub.callsFake(() => resolve(true))
+ );
+ mockIdleService._fireObservers("wake_notification");
+ mockIdleService._fireObservers("idle");
+ await sleepMs(1);
+ mockIdleService._fireObservers("active");
+ await sleepMs(inChaosMode ? 32 : 300);
+ ok(handlerStub.notCalled, "Not called immediately after waking from sleep");
+
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("active");
+ ok(
+ await firedAfterWake,
+ "Called once after waiting for wake delay before firing idle"
+ );
+ restore();
+});
+
+add_task(async function test_formAutofillTrigger() {
+ const sandbox = sinon.createSandbox();
+ const handlerStub = sandbox.stub();
+ const formAutofillTrigger = ASRouterTriggerListeners.get("formAutofill");
+ sandbox.stub(formAutofillTrigger, "_triggerDelay").value(0);
+ formAutofillTrigger.uninit();
+ formAutofillTrigger.init(handlerStub);
+
+ function notifyCreditCardSaved() {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: { sourceSync: false, collectionName: "creditCards" },
+ },
+ formAutofillTrigger._topic,
+ "add"
+ );
+ }
+
+ // Saving credit cards for autofill currently fails for some hardware
+ // configurations, so mock the event instead of really adding a card.
+ notifyCreditCardSaved();
+ await sleepMs(1);
+ Assert.ok(handlerStub.called, "Called after event");
+
+ // Test that the trigger doesn't fire when the credit card manager is open.
+ handlerStub.resetHistory();
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:preferences#privacy" },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () =>
+ (
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("#creditCardAutofill button"),
+ "Waiting for credit card manager button"
+ )
+ )?.click()
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => browser.contentWindow?.gSubDialog?.dialogs.length
+ );
+ notifyCreditCardSaved();
+ await sleepMs(1);
+ Assert.ok(
+ handlerStub.notCalled,
+ "Not called when credit card manager is open"
+ );
+ }
+ );
+
+ formAutofillTrigger.uninit();
+ handlerStub.resetHistory();
+ notifyCreditCardSaved();
+ await sleepMs(1);
+ Assert.ok(handlerStub.notCalled, "Not called after uninit");
+
+ sandbox.restore();
+ formAutofillTrigger.uninit();
+});
+
+add_task(async function test_pageActionInUrlbarTrigger() {
+ const sandbox = sinon.createSandbox();
+ const receivedTrigger = new Promise(resolve => {
+ sandbox
+ .stub(ASRouter, "sendTriggerMessage")
+ .callsFake(({ id, context }) => {
+ if (
+ id === "pageActionInUrlbar" &&
+ context?.pageAction === "picture-in-picture-button"
+ ) {
+ resolve(true);
+ }
+ });
+ });
+ sandbox
+ .stub(PictureInPicture, "getEligiblePipVideoCount")
+ .returns({ totalPipCount: 1, totalPipDisabled: 0 });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.enabled", true],
+ ["media.videocontrols.picture-in-picture.urlbar-button.enabled", true],
+ ],
+ });
+
+ PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser);
+
+ let pageAction = await receivedTrigger;
+ ok(pageAction, "pageActionInUrlbar trigger sent with PiP button id");
+
+ await SpecialPowers.popPrefEnv();
+ sandbox.restore();
+
+ PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser);
+});
diff --git a/browser/components/asrouter/tests/browser/head.js b/browser/components/asrouter/tests/browser/head.js
new file mode 100644
index 0000000000..dd5e451540
--- /dev/null
+++ b/browser/components/asrouter/tests/browser/head.js
@@ -0,0 +1,66 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs",
+ FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs",
+
+ FeatureCalloutBroker:
+ "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs",
+
+ FeatureCalloutMessages:
+ "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs",
+
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+});
+XPCOMUtils.defineLazyModuleGetters(this, {
+ QueryCache: "resource:///modules/asrouter/ASRouterTargeting.jsm",
+});
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+// We import sinon here to make it available across all mochitest test files
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+// Feature callout constants
+const calloutId = "feature-callout";
+const calloutSelector = `#${calloutId}.featureCallout`;
+const calloutCTASelector = `#${calloutId} :is(.primary, .secondary)`;
+const calloutDismissSelector = `#${calloutId} .dismiss-button`;
+
+function pushPrefs(...prefs) {
+ return SpecialPowers.pushPrefEnv({ set: prefs });
+}
+
+async function clearHistoryAndBookmarks() {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ QueryCache.expireAll();
+}
+
+/**
+ * Helper function to navigate and wait for page to load
+ * https://searchfox.org/mozilla-central/rev/314b4297e899feaf260e7a7d1a9566a218216e7a/testing/mochitest/BrowserTestUtils/BrowserTestUtils.sys.mjs#404
+ */
+async function waitForUrlLoad(url) {
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser, false, url);
+}
+
+async function waitForCalloutScreen(target, screenId) {
+ await BrowserTestUtils.waitForMutationCondition(
+ target,
+ { childList: true, subtree: true, attributeFilter: ["class"] },
+ () => target.querySelector(`${calloutSelector}:not(.hidden) .${screenId}`)
+ );
+}
+
+async function waitForCalloutRemoved(target) {
+ await BrowserTestUtils.waitForMutationCondition(
+ target,
+ { childList: true, subtree: true },
+ () => !target.querySelector(calloutSelector)
+ );
+}
diff --git a/browser/components/asrouter/tests/unit/ASRouter.test.js b/browser/components/asrouter/tests/unit/ASRouter.test.js
new file mode 100644
index 0000000000..7df1449a14
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouter.test.js
@@ -0,0 +1,2870 @@
+import { _ASRouter, MessageLoaderUtils } from "modules/ASRouter.sys.mjs";
+import { QueryCache } from "modules/ASRouterTargeting.sys.mjs";
+import {
+ FAKE_LOCAL_MESSAGES,
+ FAKE_LOCAL_PROVIDER,
+ FAKE_LOCAL_PROVIDERS,
+ FAKE_REMOTE_MESSAGES,
+ FAKE_REMOTE_PROVIDER,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+} from "./constants";
+import {
+ ASRouterPreferences,
+ TARGETING_PREFERENCES,
+} from "modules/ASRouterPreferences.sys.mjs";
+import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs";
+import { CFRPageActions } from "modules/CFRPageActions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs";
+import ProviderResponseSchema from "content-src/schemas/provider-response.schema.json";
+
+const MESSAGE_PROVIDER_PREF_NAME =
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr";
+const FAKE_PROVIDERS = [
+ FAKE_LOCAL_PROVIDER,
+ FAKE_REMOTE_PROVIDER,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+];
+const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
+const FAKE_RESPONSE_HEADERS = { get() {} };
+const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]];
+
+const USE_REMOTE_L10N_PREF =
+ "browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
+
+// eslint-disable-next-line max-statements
+describe("ASRouter", () => {
+ let Router;
+ let globals;
+ let sandbox;
+ let initParams;
+ let messageBlockList;
+ let providerBlockList;
+ let messageImpressions;
+ let groupImpressions;
+ let previousSessionEnd;
+ let fetchStub;
+ let clock;
+ let fakeAttributionCode;
+ let fakeTargetingContext;
+ let FakeToolbarBadgeHub;
+ let FakeToolbarPanelHub;
+ let FakeMomentsPageHub;
+ let ASRouterTargeting;
+ let screenImpressions;
+
+ function setMessageProviderPref(value) {
+ sandbox.stub(ASRouterPreferences, "providers").get(() => value);
+ }
+
+ function initASRouter(router) {
+ const getStub = sandbox.stub();
+ getStub.returns(Promise.resolve());
+ getStub
+ .withArgs("messageBlockList")
+ .returns(Promise.resolve(messageBlockList));
+ getStub
+ .withArgs("providerBlockList")
+ .returns(Promise.resolve(providerBlockList));
+ getStub
+ .withArgs("messageImpressions")
+ .returns(Promise.resolve(messageImpressions));
+ getStub.withArgs("groupImpressions").resolves(groupImpressions);
+ getStub
+ .withArgs("previousSessionEnd")
+ .returns(Promise.resolve(previousSessionEnd));
+ getStub
+ .withArgs("screenImpressions")
+ .returns(Promise.resolve(screenImpressions));
+ initParams = {
+ storage: {
+ get: getStub,
+ set: sandbox.stub().returns(Promise.resolve()),
+ },
+ sendTelemetry: sandbox.stub().resolves(),
+ clearChildMessages: sandbox.stub().resolves(),
+ clearChildProviders: sandbox.stub().resolves(),
+ updateAdminState: sandbox.stub().resolves(),
+ dispatchCFRAction: sandbox.stub().resolves(),
+ };
+ sandbox.stub(router, "loadMessagesFromAllProviders").callThrough();
+ return router.init(initParams);
+ }
+
+ async function createRouterAndInit(providers = FAKE_PROVIDERS) {
+ setMessageProviderPref(providers);
+ // `.freeze` to catch any attempts at modifying the object
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+ await initASRouter(Router);
+ }
+
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ messageBlockList = [];
+ providerBlockList = [];
+ messageImpressions = {};
+ groupImpressions = {};
+ previousSessionEnd = 100;
+ screenImpressions = {};
+ sandbox = sinon.createSandbox();
+ ASRouterTargeting = {
+ isMatch: sandbox.stub(),
+ findMatchingMessage: sandbox.stub(),
+ Environment: {
+ locale: "en-US",
+ localeLanguageCode: "en",
+ browserSettings: {
+ update: {
+ channel: "default",
+ enabled: true,
+ autoDownload: true,
+ },
+ },
+ attributionData: {},
+ currentDate: "2000-01-01T10:00:0.001Z",
+ profileAgeCreated: {},
+ profileAgeReset: {},
+ usesFirefoxSync: false,
+ isFxAEnabled: true,
+ isFxASignedIn: false,
+ sync: {
+ desktopDevices: 0,
+ mobileDevices: 0,
+ totalDevices: 0,
+ },
+ xpinstallEnabled: true,
+ addonsInfo: {},
+ searchEngines: {},
+ isDefaultBrowser: false,
+ devToolsOpenedCount: 5,
+ topFrecentSites: {},
+ recentBookmarks: {},
+ pinnedSites: [
+ {
+ url: "https://amazon.com",
+ host: "amazon.com",
+ searchTopSite: true,
+ },
+ ],
+ providerCohorts: {
+ onboarding: "",
+ cfr: "",
+ "message-groups": "",
+ "messaging-experiments": "",
+ "whats-new-panel": "",
+ },
+ totalBookmarksCount: {},
+ firefoxVersion: 80,
+ region: "US",
+ needsUpdate: {},
+ hasPinnedTabs: false,
+ hasAccessedFxAPanel: false,
+ isWhatsNewPanelEnabled: true,
+ userPrefs: {
+ cfrFeatures: true,
+ cfrAddons: true,
+ },
+ totalBlockedCount: {},
+ blockedCountByType: {},
+ attachedFxAOAuthClients: [],
+ platformName: "macosx",
+ scores: {},
+ scoreThreshold: 5000,
+ isChinaRepack: false,
+ userId: "adsf",
+ },
+ };
+
+ ASRouterPreferences.specialConditions = {
+ someCondition: true,
+ };
+ sandbox.spy(ASRouterPreferences, "init");
+ sandbox.spy(ASRouterPreferences, "uninit");
+ sandbox.spy(ASRouterPreferences, "addListener");
+ sandbox.spy(ASRouterPreferences, "removeListener");
+
+ clock = sandbox.useFakeTimers();
+ fetchStub = sandbox
+ .stub(global, "fetch")
+ .withArgs("http://fake.com/endpoint")
+ .resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ sandbox.stub(global.Services.prefs, "getStringPref");
+
+ fakeAttributionCode = {
+ allowedCodeKeys: ["foo", "bar", "baz"],
+ _clearCache: () => sinon.stub(),
+ getAttrDataAsync: () => Promise.resolve({ content: "addonID" }),
+ deleteFileAsync: () => Promise.resolve(),
+ writeAttributionFile: () => Promise.resolve(),
+ getCachedAttributionData: sinon.stub(),
+ };
+ FakeToolbarPanelHub = {
+ init: sandbox.stub(),
+ uninit: sandbox.stub(),
+ forceShowMessage: sandbox.stub(),
+ enableToolbarButton: sandbox.stub(),
+ };
+ FakeToolbarBadgeHub = {
+ init: sandbox.stub(),
+ uninit: sandbox.stub(),
+ registerBadgeNotificationListener: sandbox.stub(),
+ };
+ FakeMomentsPageHub = {
+ init: sandbox.stub(),
+ uninit: sandbox.stub(),
+ executeAction: sandbox.stub(),
+ };
+ fakeTargetingContext = {
+ combineContexts: sandbox.stub(),
+ evalWithDefault: sandbox.stub().resolves(),
+ };
+ let fakeNimbusFeatures = [
+ "cfr",
+ "infobar",
+ "spotlight",
+ "moments-page",
+ "pbNewtab",
+ ].reduce((features, featureId) => {
+ features[featureId] = {
+ getAllVariables: sandbox.stub().returns(null),
+ recordExposureEvent: sandbox.stub(),
+ };
+ return features;
+ }, {});
+ globals.set({
+ // Testing framework doesn't know how to `defineLazyModuleGetters` so we're
+ // importing these modules into the global scope ourselves.
+ GroupsConfigurationProvider: { getMessages: () => Promise.resolve([]) },
+ ASRouterPreferences,
+ TARGETING_PREFERENCES,
+ ASRouterTargeting,
+ ASRouterTriggerListeners,
+ QueryCache,
+ gBrowser: { selectedBrowser: {} },
+ gURLBar: {},
+ isSeparateAboutWelcome: true,
+ AttributionCode: fakeAttributionCode,
+ PanelTestProvider,
+ MacAttribution: { applicationPath: "" },
+ ToolbarBadgeHub: FakeToolbarBadgeHub,
+ ToolbarPanelHub: FakeToolbarPanelHub,
+ MomentsPageHub: FakeMomentsPageHub,
+ KintoHttpClient: class {
+ bucket() {
+ return this;
+ }
+ collection() {
+ return this;
+ }
+ getRecord() {
+ return Promise.resolve({ data: {} });
+ }
+ },
+ Downloader: class {
+ download() {
+ return Promise.resolve("/path/to/download");
+ }
+ },
+ NimbusFeatures: fakeNimbusFeatures,
+ ExperimentAPI: {
+ getExperimentMetaData: sandbox.stub().returns({
+ slug: "experiment-slug",
+ active: true,
+ branch: { slug: "experiment-branch-slug" },
+ }),
+ getExperiment: sandbox.stub().returns({
+ branch: {
+ slug: "unit-slug",
+ feature: {
+ featureId: "foo",
+ value: { id: "test-message" },
+ },
+ },
+ }),
+ getAllBranches: sandbox.stub().resolves([]),
+ ready: sandbox.stub().resolves(),
+ },
+ SpecialMessageActions: {
+ handleAction: sandbox.stub(),
+ },
+ TargetingContext: class {
+ static combineContexts(...args) {
+ return fakeTargetingContext.combineContexts.apply(sandbox, args);
+ }
+
+ evalWithDefault(expr) {
+ return fakeTargetingContext.evalWithDefault(expr);
+ }
+ },
+ RemoteL10n: {
+ // This is just a subset of supported locales that happen to be used in
+ // the test.
+ isLocaleSupported: locale => ["en-US", "ja-JP-mac"].includes(locale),
+ },
+ });
+ await createRouterAndInit();
+ });
+ afterEach(() => {
+ Router.uninit();
+ ASRouterPreferences.uninit();
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe(".state", () => {
+ it("should throw if an attempt to set .state was made", () => {
+ assert.throws(() => {
+ Router.state = {};
+ });
+ });
+ });
+
+ describe("#init", () => {
+ it("should only be called once", async () => {
+ Router = new _ASRouter();
+ let state = await initASRouter(Router);
+
+ assert.equal(state, Router.state);
+
+ assert.isNull(await Router.init({}));
+ });
+ it("should only be called once", async () => {
+ Router = new _ASRouter();
+ initASRouter(Router);
+ let secondCall = await Router.init({});
+
+ assert.isNull(
+ secondCall,
+ "Should not init twice, it should exit early with null"
+ );
+ });
+ it("should set state.messageBlockList to the block list in persistent storage", async () => {
+ messageBlockList = ["foo"];
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.deepEqual(Router.state.messageBlockList, ["foo"]);
+ });
+ it("should initialize all the hub providers", async () => {
+ // ASRouter init called in `beforeEach` block above
+
+ assert.calledOnce(FakeToolbarBadgeHub.init);
+ assert.calledOnce(FakeToolbarPanelHub.init);
+ assert.calledOnce(FakeMomentsPageHub.init);
+
+ assert.calledWithExactly(
+ FakeToolbarBadgeHub.init,
+ Router.waitForInitialized,
+ {
+ handleMessageRequest: Router.handleMessageRequest,
+ addImpression: Router.addImpression,
+ blockMessageById: Router.blockMessageById,
+ sendTelemetry: Router.sendTelemetry,
+ unblockMessageById: Router.unblockMessageById,
+ }
+ );
+
+ assert.calledWithExactly(
+ FakeToolbarPanelHub.init,
+ Router.waitForInitialized,
+ {
+ getMessages: Router.handleMessageRequest,
+ sendTelemetry: Router.sendTelemetry,
+ }
+ );
+
+ assert.calledWithExactly(
+ FakeMomentsPageHub.init,
+ Router.waitForInitialized,
+ {
+ handleMessageRequest: Router.handleMessageRequest,
+ addImpression: Router.addImpression,
+ blockMessageById: Router.blockMessageById,
+ sendTelemetry: Router.sendTelemetry,
+ }
+ );
+ });
+ it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => {
+ // Note that messageImpressions are only kept if a message exists in router and has a .frequency property,
+ // otherwise they will be cleaned up by .cleanupImpressions()
+ const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } };
+ messageImpressions = { foo: [0, 1, 2] };
+ setMessageProviderPref([
+ { id: "onboarding", type: "local", messages: [testMessage] },
+ ]);
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.deepEqual(Router.state.messageImpressions, messageImpressions);
+ });
+ it("should set state.screenImpressions to the screenImpressions object in persistent storage", async () => {
+ screenImpressions = { test: 123 };
+
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.deepEqual(Router.state.screenImpressions, screenImpressions);
+ });
+ it("should clear impressions for groups that are not active", async () => {
+ groupImpressions = { foo: [0, 1, 2] };
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.notProperty(Router.state.groupImpressions, "foo");
+ });
+ it("should keep impressions for groups that are active", async () => {
+ Router = new _ASRouter();
+ await initASRouter(Router);
+ await Router.setState(() => {
+ return {
+ groups: [
+ {
+ id: "foo",
+ enabled: true,
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 10 }],
+ lifetime: Infinity,
+ },
+ },
+ ],
+ groupImpressions: { foo: [Date.now()] },
+ };
+ });
+ Router.cleanupImpressions();
+
+ assert.property(Router.state.groupImpressions, "foo");
+ assert.lengthOf(Router.state.groupImpressions.foo, 1);
+ });
+ it("should remove old impressions for a group", async () => {
+ Router = new _ASRouter();
+ await initASRouter(Router);
+ await Router.setState(() => {
+ return {
+ groups: [
+ {
+ id: "foo",
+ enabled: true,
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 10 }],
+ },
+ },
+ ],
+ groupImpressions: {
+ foo: [Date.now() - ONE_DAY_IN_MS - 1, Date.now()],
+ },
+ };
+ });
+ Router.cleanupImpressions();
+
+ assert.property(Router.state.groupImpressions, "foo");
+ assert.lengthOf(Router.state.groupImpressions.foo, 1);
+ });
+ it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => {
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+
+ await initASRouter(Router);
+
+ assert.calledOnce(Router.loadMessagesFromAllProviders);
+ assert.isArray(Router.state.messages);
+ assert.lengthOf(
+ Router.state.messages,
+ FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length
+ );
+ });
+ it("should set state.previousSessionEnd from IndexedDB", async () => {
+ previousSessionEnd = 200;
+ await createRouterAndInit();
+
+ assert.equal(Router.state.previousSessionEnd, previousSessionEnd);
+ });
+ it("should assign ASRouterPreferences.specialConditions to state", async () => {
+ assert.isTrue(ASRouterPreferences.specialConditions.someCondition);
+ assert.isTrue(Router.state.someCondition);
+ });
+ it("should add observer for `intl:app-locales-changed`", async () => {
+ sandbox.spy(global.Services.obs, "addObserver");
+ await createRouterAndInit();
+
+ assert.calledWithExactly(
+ global.Services.obs.addObserver,
+ Router._onLocaleChanged,
+ "intl:app-locales-changed"
+ );
+ });
+ it("should add a pref observer", async () => {
+ sandbox.spy(global.Services.prefs, "addObserver");
+ await createRouterAndInit();
+
+ assert.calledOnce(global.Services.prefs.addObserver);
+ assert.calledWithExactly(
+ global.Services.prefs.addObserver,
+ USE_REMOTE_L10N_PREF,
+ Router
+ );
+ });
+ describe("lazily loading local test providers", () => {
+ afterEach(() => {
+ Router.uninit();
+ });
+ it("should add the local test providers on init if devtools are enabled", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true);
+
+ await createRouterAndInit();
+
+ assert.property(Router._localProviders, "PanelTestProvider");
+ });
+ it("should not add the local test providers on init if devtools are disabled", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false);
+
+ await createRouterAndInit();
+
+ assert.notProperty(Router._localProviders, "PanelTestProvider");
+ });
+ });
+ });
+
+ describe("preference changes", () => {
+ it("should call ASRouterPreferences.init and add a listener on init", () => {
+ assert.calledOnce(ASRouterPreferences.init);
+ assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange);
+ });
+ it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => {
+ Router.uninit();
+ assert.calledOnce(ASRouterPreferences.uninit);
+ assert.calledWith(
+ ASRouterPreferences.removeListener,
+ Router.onPrefChange
+ );
+ });
+ it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => {
+ const messageTargeted = {
+ id: "1",
+ campaign: "foocampaign",
+ targeting: "true",
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ const messageNotTargeted = {
+ id: "2",
+ campaign: "foocampaign",
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ await Router.setState({
+ messages: [messageTargeted, messageNotTargeted],
+ providers: [{ id: "cfr" }],
+ });
+ fakeTargetingContext.evalWithDefault.resolves(false);
+
+ await Router.onPrefChange("services.sync.username");
+
+ assert.calledOnce(initParams.clearChildMessages);
+ assert.calledWith(initParams.clearChildMessages, [messageTargeted.id]);
+ });
+ it("should call loadMessagesFromAllProviders on pref change", () => {
+ ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME);
+ assert.calledOnce(Router.loadMessagesFromAllProviders);
+ });
+ it("should update groups state if a user pref changes", async () => {
+ await Router.setState({
+ groups: [{ id: "foo", userPreferences: ["bar"], enabled: true }],
+ });
+ sandbox.stub(ASRouterPreferences, "getUserPreference");
+
+ await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME);
+
+ assert.calledWithExactly(ASRouterPreferences.getUserPreference, "bar");
+ });
+ it("should update the list of providers on pref change", async () => {
+ const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, {
+ url: "baz.com",
+ });
+ setMessageProviderPref([
+ FAKE_LOCAL_PROVIDER,
+ modifiedRemoteProvider,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+ ]);
+
+ const { length } = Router.state.providers;
+
+ ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME);
+ await Router._updateMessageProviders();
+
+ const provider = Router.state.providers.find(p => p.url === "baz.com");
+ assert.lengthOf(Router.state.providers, length);
+ assert.isDefined(provider);
+ });
+ it("should clear disabled providers on pref change", async () => {
+ const TEST_PROVIDER_ID = "some_provider_id";
+ await Router.setState({
+ providers: [{ id: TEST_PROVIDER_ID }],
+ });
+ const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, {
+ id: TEST_PROVIDER_ID,
+ enabled: false,
+ });
+ setMessageProviderPref([
+ FAKE_LOCAL_PROVIDER,
+ modifiedRemoteProvider,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+ ]);
+ await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME);
+
+ assert.calledOnce(initParams.clearChildProviders);
+ assert.calledWith(initParams.clearChildProviders, [TEST_PROVIDER_ID]);
+ });
+ });
+
+ describe("setState", () => {
+ it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true);
+ sandbox.stub(Router, "getTargetingParameters").resolves({});
+ const state = await Router.setState({ foo: 123 });
+
+ assert.calledOnce(initParams.updateAdminState);
+ assert.deepEqual(state.providerPrefs, ASRouterPreferences.providers);
+ assert.deepEqual(
+ state.userPrefs,
+ ASRouterPreferences.getAllUserPreferences()
+ );
+ assert.deepEqual(state.targetingParameters, {});
+ assert.deepEqual(state.errors, Router.errors);
+ });
+ it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false);
+ await Router.setState({ foo: 123 });
+
+ assert.notCalled(initParams.updateAdminState);
+ });
+ });
+
+ describe("getTargetingParameters", () => {
+ it("should return the targeting parameters", async () => {
+ const stub = sandbox.stub().resolves("foo");
+ const obj = { foo: 1 };
+ sandbox.stub(obj, "foo").get(stub);
+ const result = await Router.getTargetingParameters(obj, obj);
+
+ assert.calledTwice(stub);
+ assert.propertyVal(result, "foo", "foo");
+ });
+ });
+
+ describe("evaluateExpression", () => {
+ it("should call ASRouterTargeting to evaluate", async () => {
+ fakeTargetingContext.evalWithDefault.resolves("foo");
+ const response = await Router.evaluateExpression({});
+ assert.equal(response.evaluationStatus.result, "foo");
+ assert.isTrue(response.evaluationStatus.success);
+ });
+ it("should catch evaluation errors", async () => {
+ fakeTargetingContext.evalWithDefault.returns(
+ Promise.reject(new Error("fake error"))
+ );
+ const response = await Router.evaluateExpression({});
+ assert.isFalse(response.evaluationStatus.success);
+ });
+ });
+
+ describe("#routeCFRMessage", () => {
+ let browser;
+ beforeEach(() => {
+ sandbox.stub(CFRPageActions, "forceRecommendation");
+ sandbox.stub(CFRPageActions, "addRecommendation");
+ browser = {};
+ });
+ it("should route whatsnew_panel_message message to the right hub", () => {
+ Router.routeCFRMessage(
+ { template: "whatsnew_panel_message" },
+ browser,
+ "",
+ true
+ );
+
+ assert.calledOnce(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route moments messages to the right hub", () => {
+ Router.routeCFRMessage({ template: "update_action" }, browser, "", true);
+
+ assert.calledOnce(FakeMomentsPageHub.executeAction);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ });
+ it("should route toolbar_badge message to the right hub", () => {
+ Router.routeCFRMessage({ template: "toolbar_badge" }, browser);
+
+ assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route milestone_message to the right hub", () => {
+ Router.routeCFRMessage(
+ { template: "milestone_message" },
+ browser,
+ "",
+ false
+ );
+
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_doorhanger message to the right hub force = false", () => {
+ Router.routeCFRMessage(
+ { template: "cfr_doorhanger" },
+ browser,
+ { param: {} },
+ false
+ );
+
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_doorhanger message to the right hub force = true", () => {
+ Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true);
+
+ assert.calledOnce(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_urlbar_chiclet message to the right hub force = false", () => {
+ Router.routeCFRMessage(
+ { template: "cfr_urlbar_chiclet" },
+ browser,
+ { param: {} },
+ false
+ );
+
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ const { args } = CFRPageActions.addRecommendation.firstCall;
+ // Host should be null
+ assert.isNull(args[1]);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_urlbar_chiclet message to the right hub force = true", () => {
+ Router.routeCFRMessage(
+ { template: "cfr_urlbar_chiclet" },
+ browser,
+ {},
+ true
+ );
+
+ assert.calledOnce(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route default to sending to content", () => {
+ Router.routeCFRMessage(
+ { template: "some_other_template" },
+ browser,
+ {},
+ true
+ );
+
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ });
+
+ describe("#loadMessagesFromAllProviders", () => {
+ function assertRouterContainsMessages(messages) {
+ const messageIdsInRouter = Router.state.messages.map(m => m.id);
+ for (const message of messages) {
+ assert.include(messageIdsInRouter, message.id);
+ }
+ }
+
+ it("should not trigger an update if not enough time has passed for a provider", async () => {
+ await createRouterAndInit([
+ {
+ id: "remotey",
+ type: "remote",
+ enabled: true,
+ url: "http://fake.com/endpoint",
+ updateCycleInMs: 300,
+ },
+ ]);
+
+ const previousState = Router.state;
+
+ // Since we've previously gotten messages during init and we haven't advanced our fake timer,
+ // no updates should be triggered.
+ await Router.loadMessagesFromAllProviders();
+ assert.deepEqual(Router.state, previousState);
+ });
+ it("should not trigger an update if we only have local providers", async () => {
+ await createRouterAndInit([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: FAKE_LOCAL_MESSAGES,
+ },
+ ]);
+
+ const previousState = Router.state;
+ const stub = sandbox.stub(MessageLoaderUtils, "loadMessagesForProvider");
+
+ clock.tick(300);
+
+ await Router.loadMessagesFromAllProviders();
+
+ assert.deepEqual(Router.state, previousState);
+ assert.notCalled(stub);
+ });
+ it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => {
+ const NEW_MESSAGES = [{ id: "new_123" }];
+ await createRouterAndInit([
+ {
+ id: "remotey",
+ type: "remote",
+ url: "http://fake.com/endpoint",
+ enabled: true,
+ updateCycleInMs: 300,
+ },
+ {
+ id: "alocalprovider",
+ type: "local",
+ enabled: true,
+ messages: FAKE_LOCAL_MESSAGES,
+ },
+ ]);
+ fetchStub.withArgs("http://fake.com/endpoint").resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ messages: NEW_MESSAGES }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+
+ clock.tick(301);
+ await Router.loadMessagesFromAllProviders();
+
+ // These are the new messages
+ assertRouterContainsMessages(NEW_MESSAGES);
+ // These are the local messages that should not have been deleted
+ assertRouterContainsMessages(FAKE_LOCAL_MESSAGES);
+ });
+ it("should parse the triggers in the messages and register the trigger listeners", async () => {
+ sandbox.spy(
+ ASRouterTriggerListeners.get("openURL"),
+ "init"
+ ); /* eslint-disable object-property-newline */
+
+ /* eslint-disable object-curly-newline */ await createRouterAndInit([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: [
+ {
+ id: "foo",
+ template: "simple_template",
+ trigger: { id: "firstRun" },
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "bar1",
+ template: "simple_template",
+ trigger: {
+ id: "openURL",
+ params: ["www.mozilla.org", "www.mozilla.com"],
+ },
+ content: { title: "Bar1", body: "Bar123" },
+ },
+ {
+ id: "bar2",
+ template: "simple_template",
+ trigger: { id: "openURL", params: ["www.example.com"] },
+ content: { title: "Bar2", body: "Bar123" },
+ },
+ ],
+ },
+ ]); /* eslint-enable object-property-newline */
+ /* eslint-enable object-curly-newline */ assert.calledTwice(
+ ASRouterTriggerListeners.get("openURL").init
+ );
+ assert.calledWithExactly(
+ ASRouterTriggerListeners.get("openURL").init,
+ Router._triggerHandler,
+ ["www.mozilla.org", "www.mozilla.com"],
+ undefined
+ );
+ assert.calledWithExactly(
+ ASRouterTriggerListeners.get("openURL").init,
+ Router._triggerHandler,
+ ["www.example.com"],
+ undefined
+ );
+ });
+ it("should parse the message's messagesLoaded trigger and immediately fire trigger", async () => {
+ setMessageProviderPref([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: [
+ {
+ id: "bar3",
+ template: "simple_template",
+ trigger: { id: "messagesLoaded" },
+ content: { title: "Bar3", body: "Bar123" },
+ },
+ ],
+ },
+ ]);
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+ sandbox.spy(Router, "sendTriggerMessage");
+ await initASRouter(Router);
+ assert.calledOnce(Router.sendTriggerMessage);
+ assert.calledWith(
+ Router.sendTriggerMessage,
+ sandbox.match({ id: "messagesLoaded" }),
+ true
+ );
+ });
+ it("should gracefully handle messages loading before a window or browser exists", async () => {
+ sandbox.stub(global, "gBrowser").value(undefined);
+ sandbox
+ .stub(global.Services.wm, "getMostRecentBrowserWindow")
+ .returns(undefined);
+ setMessageProviderPref([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: [
+ "whatsnew_panel_message",
+ "cfr_doorhanger",
+ "toolbar_badge",
+ "update_action",
+ "infobar",
+ "spotlight",
+ "toast_notification",
+ ].map((template, i) => {
+ return {
+ id: `foo${i}`,
+ template,
+ trigger: { id: "messagesLoaded" },
+ content: { title: `Foo${i}`, body: "Bar123" },
+ };
+ }),
+ },
+ ]);
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+ sandbox.spy(Router, "sendTriggerMessage");
+ await initASRouter(Router);
+ assert.calledWith(
+ Router.sendTriggerMessage,
+ sandbox.match({ id: "messagesLoaded" }),
+ true
+ );
+ });
+ it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => {
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .rejects("fake error");
+ await createRouterAndInit();
+ assert.calledWith(initParams.dispatchCFRAction, {
+ data: {
+ action: "asrouter_undesired_event",
+ event: "ASR_RS_ERROR",
+ event_context: "remotey-settingsy",
+ message_id: "n/a",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "AS_ROUTER_TELEMETRY_USER_EVENT",
+ });
+ });
+ it("should dispatch undesired event if RemoteSettings returns no messages", async () => {
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([]);
+ assert.calledWith(initParams.dispatchCFRAction, {
+ data: {
+ action: "asrouter_undesired_event",
+ event: "ASR_RS_NO_MESSAGES",
+ event_context: "remotey-settingsy",
+ message_id: "n/a",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "AS_ROUTER_TELEMETRY_USER_EVENT",
+ });
+ });
+ it("should download the attachment if RemoteSettings returns some messages", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([{ id: "message_1" }]);
+ const spy = sandbox.spy();
+ global.Downloader.prototype.downloadToDisk = spy;
+ const provider = {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ };
+ await createRouterAndInit([provider]);
+
+ assert.calledOnce(spy);
+ });
+ it("should dispatch undesired event if the ms-language-packs returns no messages", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([{ id: "message_1" }]);
+ sandbox
+ .stub(global.KintoHttpClient.prototype, "getRecord")
+ .resolves(null);
+ const provider = {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ };
+ await createRouterAndInit([provider]);
+
+ assert.calledWith(initParams.dispatchCFRAction, {
+ data: {
+ action: "asrouter_undesired_event",
+ event: "ASR_RS_NO_MESSAGES",
+ event_context: "ms-language-packs",
+ message_id: "n/a",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "AS_ROUTER_TELEMETRY_USER_EVENT",
+ });
+ });
+ });
+
+ describe("#_updateMessageProviders", () => {
+ it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", async () => {
+ // If this test fails, you need to update the constant STARTPAGE_VERSION in
+ // ASRouter.sys.mjs to match the `version` property of provider-response-schema.json
+ const expectedStartpageVersion = ProviderResponseSchema.version;
+ const provider = {
+ id: "foo",
+ enabled: true,
+ type: "remote",
+ url: "https://www.mozilla.org/%STARTPAGE_VERSION%/",
+ };
+ setMessageProviderPref([provider]);
+ await Router._updateMessageProviders();
+ assert.equal(
+ Router.state.providers[0].url,
+ `https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/`
+ );
+ });
+ it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", async () => {
+ const url = "https://www.example.com/";
+ const replacedUrl = "https://www.foo.bar/";
+ const stub = sandbox
+ .stub(global.Services.urlFormatter, "formatURL")
+ .withArgs(url)
+ .returns(replacedUrl);
+ const provider = { id: "foo", enabled: true, type: "remote", url };
+ setMessageProviderPref([provider]);
+ await Router._updateMessageProviders();
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, url);
+ assert.equal(Router.state.providers[0].url, replacedUrl);
+ });
+ it("should only add the providers that are enabled", async () => {
+ const providers = [
+ {
+ id: "foo",
+ enabled: false,
+ type: "remote",
+ url: "https://www.foo.com/",
+ },
+ {
+ id: "bar",
+ enabled: true,
+ type: "remote",
+ url: "https://www.bar.com/",
+ },
+ ];
+ setMessageProviderPref(providers);
+ await Router._updateMessageProviders();
+ assert.equal(Router.state.providers.length, 1);
+ assert.equal(Router.state.providers[0].id, providers[1].id);
+ });
+ });
+
+ describe("#handleMessageRequest", () => {
+ beforeEach(async () => {
+ await Router.setState(() => ({
+ providers: [{ id: "cfr" }, { id: "badge" }],
+ }));
+ });
+ it("should not return a blocked message", async () => {
+ // Block all messages except the first
+ await Router.setState(() => ({
+ messages: [
+ { id: "foo", provider: "cfr", groups: ["cfr"] },
+ { id: "bar", provider: "cfr", groups: ["cfr"] },
+ ],
+ messageBlockList: ["foo"],
+ }));
+ await Router.handleMessageRequest({
+ provider: "cfr",
+ });
+ assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, {
+ messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }],
+ });
+ });
+ it("should not return a message from a disabled group", async () => {
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => messages[0]
+ );
+ // Block all messages except the first
+ await Router.setState(() => ({
+ messages: [
+ { id: "foo", provider: "cfr", groups: ["cfr"] },
+ { id: "bar", provider: "cfr", groups: ["cfr"] },
+ ],
+ groups: [{ id: "cfr", enabled: false }],
+ }));
+ const result = await Router.handleMessageRequest({
+ provider: "cfr",
+ });
+ assert.isNull(result);
+ });
+ it("should not return a message from a blocked campaign", async () => {
+ // Block all messages except the first
+ await Router.setState(() => ({
+ messages: [
+ {
+ id: "foo",
+ provider: "cfr",
+ campaign: "foocampaign",
+ groups: ["cfr"],
+ },
+ { id: "bar", provider: "cfr", groups: ["cfr"] },
+ ],
+ messageBlockList: ["foocampaign"],
+ }));
+
+ await Router.handleMessageRequest({
+ provider: "cfr",
+ });
+ assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, {
+ messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }],
+ });
+ });
+ it("should not return a message excluded by the provider", async () => {
+ // There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving
+ // only FAKE_REMOTE_PROVIDER unblocked, which provides only one message
+ await Router.setState(() => ({
+ providers: [{ id: "cfr", exclude: ["foo"] }],
+ }));
+
+ await Router.setState(() => ({
+ messages: [{ id: "foo", provider: "cfr" }],
+ messageBlockList: ["foocampaign"],
+ }));
+
+ const result = await Router.handleMessageRequest({
+ provider: "cfr",
+ });
+ assert.isNull(result);
+ });
+ it("should not return a message if the frequency cap has been hit", async () => {
+ sandbox.stub(Router, "isBelowFrequencyCaps").returns(false);
+ await Router.setState(() => ({
+ messages: [{ id: "foo", provider: "cfr" }],
+ }));
+ const result = await Router.handleMessageRequest({
+ provider: "cfr",
+ });
+ assert.isNull(result);
+ });
+ it("should get unblocked messages that match the trigger", async () => {
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => messages[0]
+ );
+
+ const result = Router.handleMessageRequest({ triggerId: "foo" });
+
+ assert.deepEqual(result, message1);
+ });
+ it("should get unblocked messages that match trigger and template", async () => {
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ template: "badge",
+ trigger: { id: "foo" },
+ groups: ["badge"],
+ provider: "badge",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ template: "test_template",
+ trigger: { id: "foo" },
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => messages[0]
+ );
+
+ const result = Router.handleMessageRequest({
+ triggerId: "foo",
+ template: "badge",
+ });
+
+ assert.deepEqual(result, message1);
+ });
+ it("should have messageImpressions in the message context", () => {
+ assert.propertyVal(
+ Router._getMessagesContext(),
+ "messageImpressions",
+ Router.state.messageImpressions
+ );
+ });
+ it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => {
+ const message1 = {
+ provider: "whats_new",
+ id: "1",
+ template: "whatsnew_panel_message",
+ trigger: { id: "whatsNewPanelOpened" },
+ groups: ["whats_new"],
+ };
+ const message2 = {
+ provider: "whats_new",
+ id: "2",
+ template: "whatsnew_panel_message",
+ trigger: { id: "whatsNewPanelOpened" },
+ groups: ["whats_new"],
+ };
+ const message3 = {
+ provider: "whats_new",
+ id: "3",
+ template: "badge",
+ groups: ["whats_new"],
+ };
+ ASRouterTargeting.findMatchingMessage.callsFake(() => [
+ message2,
+ message1,
+ ]);
+ await Router.setState({
+ messages: [message3, message2, message1],
+ providers: [{ id: "whats_new" }],
+ });
+ const result = await Router.handleMessageRequest({
+ template: "whatsnew_panel_message",
+ triggerId: "whatsNewPanelOpened",
+ returnAll: true,
+ });
+
+ assert.deepEqual(result, [message2, message1]);
+ });
+ it("should forward trigger param info", async () => {
+ const trigger = {
+ triggerId: "foo",
+ triggerParam: "bar",
+ triggerContext: "context",
+ };
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["badge"],
+ provider: "badge",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+
+ Router.handleMessageRequest(trigger);
+
+ assert.calledOnce(ASRouterTargeting.findMatchingMessage);
+
+ const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args;
+ assert.propertyVal(options.trigger, "id", trigger.triggerId);
+ assert.propertyVal(options.trigger, "param", trigger.triggerParam);
+ assert.propertyVal(options.trigger, "context", trigger.triggerContext);
+ });
+ it("should not cache badge messages", async () => {
+ const trigger = {
+ triggerId: "bar",
+ triggerParam: "bar",
+ triggerContext: "context",
+ };
+ const message1 = {
+ id: "1",
+ provider: "cfr",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["cfr"],
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["badge"],
+ provider: "badge",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+
+ Router.handleMessageRequest(trigger);
+
+ assert.calledOnce(ASRouterTargeting.findMatchingMessage);
+
+ const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args;
+ assert.propertyVal(options, "shouldCache", false);
+ });
+ it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => {
+ const trigger = { triggerId: "foo" };
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ const message3 = {
+ id: "3",
+ campaign: "bazcampaign",
+ groups: ["cfr"],
+ provider: "cfr",
+ };
+ await Router.setState({
+ messages: [message2, message1, message3],
+ groups: [{ id: "cfr", enabled: true }],
+ });
+ // Just return the first message provided as arg
+ ASRouterTargeting.findMatchingMessage.callsFake(args => args.messages);
+
+ const result = Router.handleMessageRequest(trigger);
+
+ assert.lengthOf(result, 1);
+ assert.deepEqual(result[0], message1);
+ });
+ });
+
+ describe("#uninit", () => {
+ it("should unregister the trigger listeners", () => {
+ for (const listener of ASRouterTriggerListeners.values()) {
+ sandbox.spy(listener, "uninit");
+ }
+
+ Router.uninit();
+
+ for (const listener of ASRouterTriggerListeners.values()) {
+ assert.calledOnce(listener.uninit);
+ }
+ });
+ it("should set .dispatchCFRAction to null", () => {
+ Router.uninit();
+ assert.isNull(Router.dispatchCFRAction);
+ assert.isNull(Router.clearChildMessages);
+ assert.isNull(Router.sendTelemetry);
+ });
+ it("should save previousSessionEnd", () => {
+ Router.uninit();
+
+ assert.calledOnce(Router._storage.set);
+ assert.calledWithExactly(
+ Router._storage.set,
+ "previousSessionEnd",
+ sinon.match.number
+ );
+ });
+ it("should remove the observer for `intl:app-locales-changed`", () => {
+ sandbox.spy(global.Services.obs, "removeObserver");
+ Router.uninit();
+
+ assert.calledWithExactly(
+ global.Services.obs.removeObserver,
+ Router._onLocaleChanged,
+ "intl:app-locales-changed"
+ );
+ });
+ it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => {
+ sandbox.spy(global.Services.prefs, "removeObserver");
+ Router.uninit();
+
+ // Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`.
+ const call = global.Services.prefs.removeObserver.lastCall;
+ assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router);
+ });
+ });
+
+ describe("#setMessageById", async () => {
+ it("should send an empty message if provided id did not resolve to a message", async () => {
+ let response = await Router.setMessageById({ id: -1 }, true, {});
+ assert.deepEqual(response.message, {});
+ });
+ });
+
+ describe("#isUnblockedMessage", () => {
+ it("should block a message if the group is blocked", async () => {
+ const msg = { id: "msg1", groups: ["foo"], provider: "unit-test" };
+ await Router.setState({
+ groups: [{ id: "foo", enabled: false }],
+ messages: [msg],
+ providers: [{ id: "unit-test" }],
+ });
+ assert.isFalse(Router.isUnblockedMessage(msg));
+
+ await Router.setState({ groups: [{ id: "foo", enabled: true }] });
+
+ assert.isTrue(Router.isUnblockedMessage(msg));
+ });
+ it("should block a message if at least one group is blocked", async () => {
+ const msg = {
+ id: "msg1",
+ groups: ["foo", "bar"],
+ provider: "unit-test",
+ };
+ await Router.setState({
+ groups: [
+ { id: "foo", enabled: false },
+ { id: "bar", enabled: false },
+ ],
+ messages: [msg],
+ providers: [{ id: "unit-test" }],
+ });
+ assert.isFalse(Router.isUnblockedMessage(msg));
+
+ await Router.setState({
+ groups: [
+ { id: "foo", enabled: true },
+ { id: "bar", enabled: false },
+ ],
+ });
+
+ assert.isFalse(Router.isUnblockedMessage(msg));
+ });
+ });
+
+ describe("#blockMessageById", () => {
+ it("should add the id to the messageBlockList", async () => {
+ await Router.blockMessageById("foo");
+ assert.isTrue(Router.state.messageBlockList.includes("foo"));
+ });
+ it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => {
+ await Router.setState({
+ messages: [
+ { id: "1", campaign: "foocampaign" },
+ { id: "2", campaign: "foocampaign" },
+ ],
+ });
+ await Router.blockMessageById("1");
+
+ assert.isTrue(Router.state.messageBlockList.includes("foocampaign"));
+ assert.isEmpty(Router.state.messages.filter(Router.isUnblockedMessage));
+ });
+ it("should be able to add multiple items to the messageBlockList", async () => {
+ await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id));
+ assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id));
+ assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id));
+ });
+ it("should save the messageBlockList", async () => {
+ await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id));
+ assert.calledWithExactly(Router._storage.set, "messageBlockList", [
+ FAKE_BUNDLE[0].id,
+ FAKE_BUNDLE[1].id,
+ ]);
+ });
+ });
+
+ describe("#unblockMessageById", () => {
+ it("should remove the id from the messageBlockList", async () => {
+ await Router.blockMessageById("foo");
+ assert.isTrue(Router.state.messageBlockList.includes("foo"));
+ await Router.unblockMessageById("foo");
+ assert.isFalse(Router.state.messageBlockList.includes("foo"));
+ });
+ it("should remove the campaign from the messageBlockList if it is defined", async () => {
+ await Router.setState({ messages: [{ id: "1", campaign: "foo" }] });
+ await Router.blockMessageById("1");
+ assert.isTrue(
+ Router.state.messageBlockList.includes("foo"),
+ "blocklist has campaign id"
+ );
+ await Router.unblockMessageById("1");
+ assert.isFalse(
+ Router.state.messageBlockList.includes("foo"),
+ "campaign id removed from blocklist"
+ );
+ });
+ it("should save the messageBlockList", async () => {
+ await Router.unblockMessageById("foo");
+ assert.calledWithExactly(Router._storage.set, "messageBlockList", []);
+ });
+ });
+
+ describe("#routeCFRMessage", () => {
+ it("should allow for echoing back message modifications", () => {
+ const message = { somekey: "some value" };
+ const data = { content: message };
+ const browser = {};
+ let msg = Router.routeCFRMessage(data.content, browser, data, false);
+ assert.deepEqual(msg.message, message);
+ });
+ it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => {
+ sandbox.stub(CFRPageActions, "forceRecommendation");
+ const testMessage = { id: "foo", template: "cfr_doorhanger" };
+ await Router.setState({ messages: [testMessage] });
+ Router.routeCFRMessage(testMessage, {}, null, true);
+
+ assert.calledOnce(CFRPageActions.forceRecommendation);
+ });
+ it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => {
+ sandbox.stub(CFRPageActions, "addRecommendation");
+ const testMessage = { id: "foo", template: "cfr_doorhanger" };
+ await Router.setState({ messages: [testMessage] });
+ Router.routeCFRMessage(testMessage, {}, {}, false);
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ });
+ });
+
+ describe("#updateTargetingParameters", () => {
+ it("should return an object containing the whole state", async () => {
+ sandbox.stub(Router, "getTargetingParameters").resolves({});
+ let msg = await Router.updateTargetingParameters();
+ let expected = Object.assign({}, Router.state, {
+ providerPrefs: ASRouterPreferences.providers,
+ userPrefs: ASRouterPreferences.getAllUserPreferences(),
+ targetingParameters: {},
+ errors: Router.errors,
+ devtoolsEnabled: ASRouterPreferences.devtoolsEnabled,
+ });
+
+ assert.deepEqual(msg, expected);
+ });
+ });
+
+ describe("#reachEvent", () => {
+ let experimentAPIStub;
+ let featureIds = ["cfr", "moments-page", "infobar", "spotlight"];
+ beforeEach(() => {
+ let getExperimentMetaDataStub = sandbox.stub();
+ let getAllBranchesStub = sandbox.stub();
+ featureIds.forEach(feature => {
+ global.NimbusFeatures[feature].getAllVariables.returns({
+ id: `message-${feature}`,
+ });
+ getExperimentMetaDataStub.withArgs({ featureId: feature }).returns({
+ slug: `slug-${feature}`,
+ branch: {
+ slug: `branch-${feature}`,
+ },
+ });
+ getAllBranchesStub.withArgs(`slug-${feature}`).resolves([
+ {
+ slug: `other-branch-${feature}`,
+ [feature]: { value: { trigger: "unit-test" } },
+ },
+ ]);
+ });
+ experimentAPIStub = {
+ getExperimentMetaData: getExperimentMetaDataStub,
+ getAllBranches: getAllBranchesStub,
+ };
+ globals.set("ExperimentAPI", experimentAPIStub);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should tag `forReachEvent` for all the expected message types", async () => {
+ // This should match the `providers.messaging-experiments`
+ let response = await MessageLoaderUtils.loadMessagesForProvider({
+ type: "remote-experiments",
+ featureIds,
+ });
+
+ // 1 message for reach 1 for expose
+ assert.property(response, "messages");
+ assert.lengthOf(response.messages, featureIds.length * 2);
+ assert.lengthOf(
+ response.messages.filter(m => m.forReachEvent),
+ featureIds.length
+ );
+ });
+ });
+
+ describe("#sendTriggerMessage", () => {
+ it("should pass the trigger to ASRouterTargeting when sending trigger message", async () => {
+ await Router.setState({
+ messages: [
+ {
+ id: "foo1",
+ provider: "onboarding",
+ template: "onboarding",
+ trigger: { id: "firstRun" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ groups: ["onboarding"],
+ },
+ ],
+ providers: [{ id: "onboarding" }],
+ });
+
+ Router.loadMessagesFromAllProviders.resetHistory();
+ Router.loadMessagesFromAllProviders.onFirstCall().resolves();
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "firstRun",
+ });
+
+ assert.calledOnce(ASRouterTargeting.findMatchingMessage);
+ assert.deepEqual(
+ ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger,
+ {
+ id: "firstRun",
+ param: undefined,
+ context: undefined,
+ }
+ );
+ });
+ it("should record telemetry information", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+
+ const tabId = 123;
+
+ await Router.sendTriggerMessage({
+ tabId,
+ browser: {},
+ id: "firstRun",
+ });
+
+ assert.calledTwice(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { tabId }
+ );
+ assert.calledTwice(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { tabId }
+ );
+ });
+ it("should have previousSessionEnd in the message context", () => {
+ assert.propertyVal(
+ Router._getMessagesContext(),
+ "previousSessionEnd",
+ 100
+ );
+ });
+ it("should record the Reach event if found any", async () => {
+ let messages = [
+ {
+ id: "foo1",
+ forReachEvent: { sent: false, group: "cfr" },
+ experimentSlug: "exp01",
+ branchSlug: "branch01",
+ template: "simple_template",
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ {
+ id: "foo2",
+ template: "simple_template",
+ trigger: { id: "bar" },
+ content: { title: "Foo2", body: "Foo123-2" },
+ provider: "onboarding",
+ },
+ {
+ id: "foo3",
+ forReachEvent: { sent: false, group: "cfr" },
+ experimentSlug: "exp02",
+ branchSlug: "branch02",
+ template: "simple_template",
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ ];
+ sandbox.stub(Router, "handleMessageRequest").resolves(messages);
+ sandbox.spy(Services.telemetry, "recordEvent");
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "foo",
+ });
+
+ assert.calledTwice(Services.telemetry.recordEvent);
+ });
+ it("should not record the Reach event if it's already sent", async () => {
+ let messages = [
+ {
+ id: "foo1",
+ forReachEvent: { sent: true, group: "cfr" },
+ experimentSlug: "exp01",
+ branchSlug: "branch01",
+ template: "simple_template",
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ ];
+ sandbox.stub(Router, "handleMessageRequest").resolves(messages);
+ sandbox.spy(Services.telemetry, "recordEvent");
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "foo",
+ });
+ assert.notCalled(Services.telemetry.recordEvent);
+ });
+ it("should record the Exposure event for each valid feature", async () => {
+ ["cfr_doorhanger", "update_action", "infobar", "spotlight"].forEach(
+ async template => {
+ let featureMap = {
+ cfr_doorhanger: "cfr",
+ spotlight: "spotlight",
+ infobar: "infobar",
+ update_action: "moments-page",
+ };
+ assert.notCalled(
+ global.NimbusFeatures[featureMap[template]].recordExposureEvent
+ );
+
+ let messages = [
+ {
+ id: "foo1",
+ template,
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ ];
+ sandbox.stub(Router, "handleMessageRequest").resolves(messages);
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "foo",
+ });
+
+ assert.calledOnce(
+ global.NimbusFeatures[featureMap[template]].recordExposureEvent
+ );
+ }
+ );
+ });
+ });
+
+ describe("forceAttribution", () => {
+ let setAttributionString;
+ beforeEach(() => {
+ setAttributionString = sandbox.spy(Router, "setAttributionString");
+ sandbox.stub(global.Services.env, "set");
+ });
+ afterEach(() => {
+ sandbox.reset();
+ });
+ it("should double encode on windows", async () => {
+ sandbox.stub(fakeAttributionCode, "writeAttributionFile");
+
+ Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" });
+
+ assert.notCalled(setAttributionString);
+ assert.calledWithMatch(
+ fakeAttributionCode.writeAttributionFile,
+ "foo%3DFOO!%26bar%3DBAR%253F"
+ );
+ });
+ it("should set attribution string on mac", async () => {
+ sandbox.stub(global.AppConstants, "platform").value("macosx");
+
+ Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" });
+
+ assert.calledOnce(setAttributionString);
+ assert.calledWithMatch(
+ setAttributionString,
+ "foo%3DFOO!%26bar%3DBAR%253F"
+ );
+ });
+ });
+
+ describe("#forceWNPanel", () => {
+ let browser = {
+ ownerGlobal: {
+ document: new Document(),
+ PanelUI: {
+ showSubView: sinon.stub(),
+ panel: {
+ setAttribute: sinon.stub(),
+ },
+ },
+ },
+ };
+ let fakePanel = {
+ setAttribute: sinon.stub(),
+ };
+ sinon
+ .stub(browser.ownerGlobal.document, "getElementById")
+ .returns(fakePanel);
+
+ it("should call enableToolbarButton", async () => {
+ await Router.forceWNPanel(browser);
+ assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton);
+ assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView);
+ assert.calledWith(fakePanel.setAttribute, "noautohide", true);
+ });
+ });
+
+ describe("_triggerHandler", () => {
+ it("should call #sendTriggerMessage with the correct trigger", () => {
+ const getter = sandbox.stub();
+ getter.returns(false);
+ sandbox.stub(global.BrowserHandler, "kiosk").get(getter);
+ sinon.spy(Router, "sendTriggerMessage");
+ const browser = {};
+ const trigger = { id: "FAKE_TRIGGER", param: "some fake param" };
+ Router._triggerHandler(browser, trigger);
+ assert.calledOnce(Router.sendTriggerMessage);
+ assert.calledWith(
+ Router.sendTriggerMessage,
+ sandbox.match({
+ id: "FAKE_TRIGGER",
+ param: "some fake param",
+ })
+ );
+ });
+ });
+
+ describe("_triggerHandler_kiosk", () => {
+ it("should not call #sendTriggerMessage", () => {
+ const getter = sandbox.stub();
+ getter.returns(true);
+ sandbox.stub(global.BrowserHandler, "kiosk").get(getter);
+ sinon.spy(Router, "sendTriggerMessage");
+ const browser = {};
+ const trigger = { id: "FAKE_TRIGGER", param: "some fake param" };
+ Router._triggerHandler(browser, trigger);
+ assert.notCalled(Router.sendTriggerMessage);
+ });
+ });
+
+ describe("valid preview endpoint", () => {
+ it("should report an error if url protocol is not https", () => {
+ sandbox.stub(console, "error");
+
+ assert.equal(false, Router._validPreviewEndpoint("http://foo.com"));
+ assert.calledTwice(console.error);
+ });
+ });
+
+ describe("impressions", () => {
+ describe("#addImpression for groups", () => {
+ it("should save an impression in each group-with-frequency in a message", async () => {
+ const fooMessageImpressions = [0];
+ const aGroupImpressions = [0, 1, 2];
+ const bGroupImpressions = [3, 4, 5];
+ const cGroupImpressions = [6, 7, 8];
+
+ const message = {
+ id: "foo",
+ provider: "bar",
+ groups: ["a", "b", "c"],
+ };
+ const groups = [
+ { id: "a", frequency: { lifetime: 3 } },
+ { id: "b", frequency: { lifetime: 4 } },
+ { id: "c", frequency: { lifetime: 5 } },
+ ];
+ await Router.setState(state => {
+ // Add provider
+ const providers = [...state.providers];
+ // Add fooMessageImpressions
+ // eslint-disable-next-line no-shadow
+ const messageImpressions = Object.assign(
+ {},
+ state.messageImpressions
+ );
+ let gImpressions = {};
+ gImpressions.a = aGroupImpressions;
+ gImpressions.b = bGroupImpressions;
+ gImpressions.c = cGroupImpressions;
+ messageImpressions.foo = fooMessageImpressions;
+ return {
+ providers,
+ messageImpressions,
+ groups,
+ groupImpressions: gImpressions,
+ };
+ });
+
+ await Router.addImpression(message);
+
+ assert.deepEqual(
+ Router.state.groupImpressions.a,
+ [0, 1, 2, 0],
+ "a impressions"
+ );
+ assert.deepEqual(
+ Router.state.groupImpressions.b,
+ [3, 4, 5, 0],
+ "b impressions"
+ );
+ assert.deepEqual(
+ Router.state.groupImpressions.c,
+ [6, 7, 8, 0],
+ "c impressions"
+ );
+ });
+ });
+
+ describe("#isBelowFrequencyCaps", () => {
+ it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => {
+ sinon.spy(Router, "_isBelowItemFrequencyCap");
+
+ const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter
+ const fooMessageImpressions = [0, 1];
+ const barGroupImpressions = [0, 1, 2];
+
+ const message = {
+ id: "foo",
+ provider: "bar",
+ groups: ["bar"],
+ frequency: { lifetime: 3 },
+ };
+ const groups = [{ id: "bar", frequency: { lifetime: 5 } }];
+
+ await Router.setState(state => {
+ // Add provider
+ const providers = [...state.providers];
+ // Add fooMessageImpressions
+ // eslint-disable-next-line no-shadow
+ const messageImpressions = Object.assign(
+ {},
+ state.messageImpressions
+ );
+ let gImpressions = {};
+ gImpressions.bar = barGroupImpressions;
+ messageImpressions.foo = fooMessageImpressions;
+ return {
+ providers,
+ messageImpressions,
+ groups,
+ groupImpressions: gImpressions,
+ };
+ });
+
+ await Router.isBelowFrequencyCaps(message);
+
+ assert.calledTwice(Router._isBelowItemFrequencyCap);
+ assert.calledWithExactly(
+ Router._isBelowItemFrequencyCap,
+ message,
+ fooMessageImpressions,
+ MAX_MESSAGE_LIFETIME_CAP
+ );
+ assert.calledWithExactly(
+ Router._isBelowItemFrequencyCap,
+ groups[0],
+ barGroupImpressions
+ );
+ });
+ });
+
+ describe("#_isBelowItemFrequencyCap", () => {
+ it("should return false if the # of impressions exceeds the maxLifetimeCap", () => {
+ const item = { id: "foo", frequency: { lifetime: 5 } };
+ const impressions = [0, 1];
+ const maxLifetimeCap = 1;
+ const result = Router._isBelowItemFrequencyCap(
+ item,
+ impressions,
+ maxLifetimeCap
+ );
+ assert.isFalse(result);
+ });
+
+ describe("lifetime frequency caps", () => {
+ it("should return true if .frequency is not defined on the item", () => {
+ const item = { id: "foo" };
+ const impressions = [0, 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return true if there are no impressions", () => {
+ const item = {
+ id: "foo",
+ frequency: {
+ lifetime: 10,
+ custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
+ },
+ };
+ const impressions = [];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => {
+ const item = { id: "foo", frequency: { lifetime: 3 } };
+ const impressions = [0, 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => {
+ const item = { id: "foo", frequency: { lifetime: 3 } };
+ const impressions = [0, 1, 2];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => {
+ const item = { id: "foo", frequency: { lifetime: 3 } };
+ const impressions = [0, 1, 2, 3];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ });
+
+ describe("custom frequency caps", () => {
+ it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "foo",
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
+ lifetime: 3,
+ },
+ };
+ const impressions = [0, ONE_DAY_IN_MS + 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => {
+ clock.tick(200);
+ const item = {
+ id: "msg1",
+ frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 },
+ };
+ const impressions = [0, 160, 161];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 200);
+ const itemTrue = {
+ id: "msg2",
+ frequency: { custom: [{ period: 100, cap: 2 }] },
+ };
+ const itemFalse = {
+ id: "msg1",
+ frequency: {
+ custom: [
+ { period: 100, cap: 2 },
+ { period: ONE_DAY_IN_MS, cap: 3 },
+ ],
+ },
+ };
+ const impressions = [
+ 0,
+ ONE_DAY_IN_MS + 160,
+ ONE_DAY_IN_MS - 100,
+ ONE_DAY_IN_MS - 200,
+ ];
+ assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions));
+ assert.isFalse(
+ Router._isBelowItemFrequencyCap(itemFalse, impressions)
+ );
+ });
+ it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "msg1",
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
+ lifetime: 3,
+ },
+ };
+ const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "msg1",
+ frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] },
+ };
+ const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "msg1",
+ frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] },
+ };
+ const impressions = [
+ 0,
+ 1,
+ 2,
+ 3,
+ ONE_DAY_IN_MS + 1,
+ ONE_DAY_IN_MS + 2,
+ ONE_DAY_IN_MS + 3,
+ ];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ });
+ });
+
+ describe("#getLongestPeriod", () => {
+ it("should return the period if there is only one definition", () => {
+ const message = {
+ id: "foo",
+ frequency: { custom: [{ period: 200, cap: 2 }] },
+ };
+ assert.equal(Router.getLongestPeriod(message), 200);
+ });
+ it("should return the longest period if there are more than one definitions", () => {
+ const message = {
+ id: "foo",
+ frequency: {
+ custom: [
+ { period: 1000, cap: 3 },
+ { period: ONE_DAY_IN_MS, cap: 5 },
+ { period: 100, cap: 2 },
+ ],
+ },
+ };
+ assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS);
+ });
+ it("should return null if there are is no .frequency", () => {
+ const message = { id: "foo" };
+ assert.isNull(Router.getLongestPeriod(message));
+ });
+ it("should return null if there are is no .frequency.custom", () => {
+ const message = { id: "foo", frequency: { lifetime: 10 } };
+ assert.isNull(Router.getLongestPeriod(message));
+ });
+ });
+
+ describe("cleanup on init", () => {
+ it("should clear messageImpressions for messages which do not exist in state.messages", async () => {
+ const messages = [{ id: "foo", frequency: { lifetime: 10 } }];
+ messageImpressions = { foo: [0], bar: [0, 1] };
+ // Impressions for "bar" should be removed since that id does not exist in messages
+ const result = { foo: [0] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => {
+ const CURRENT_TIME = ONE_DAY_IN_MS * 2;
+ clock.tick(CURRENT_TIME);
+ const messages = [
+ {
+ id: "foo",
+ frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] },
+ },
+ ];
+ messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] };
+ // Only 0 and 1 are more than 24 hours before CURRENT_TIME
+ const result = { foo: [CURRENT_TIME - 10] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => {
+ const CURRENT_TIME = ONE_DAY_IN_MS * 2;
+ clock.tick(CURRENT_TIME);
+ const messages = [
+ {
+ id: "foo",
+ frequency: {
+ custom: [
+ { period: ONE_DAY_IN_MS, cap: 5 },
+ { period: 100, cap: 2 },
+ ],
+ },
+ },
+ ];
+ messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] };
+ // Only 0 and 1 are more than 24 hours before CURRENT_TIME
+ const result = { foo: [CURRENT_TIME - 10] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should clear messageImpressions if they are not properly formatted", async () => {
+ const messages = [{ id: "foo", frequency: { lifetime: 10 } }];
+ // this is impromperly formatted since messageImpressions are supposed to be an array
+ messageImpressions = { foo: 0 };
+ const result = {};
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should not clear messageImpressions for messages which do exist in state.messages", async () => {
+ const messages = [
+ { id: "foo", frequency: { lifetime: 10 } },
+ { id: "bar", frequency: { lifetime: 10 } },
+ ];
+ messageImpressions = { foo: [0], bar: [] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.notCalled(Router._storage.set);
+ assert.deepEqual(Router.state.messageImpressions, messageImpressions);
+ });
+ });
+ });
+
+ describe("#_onLocaleChanged", () => {
+ it("should call _maybeUpdateL10nAttachment in the handler", async () => {
+ sandbox.spy(Router, "_maybeUpdateL10nAttachment");
+ await Router._onLocaleChanged();
+
+ assert.calledOnce(Router._maybeUpdateL10nAttachment);
+ });
+ });
+
+ describe("#_maybeUpdateL10nAttachment", () => {
+ it("should update the l10n attachment if the locale was changed", async () => {
+ const getter = sandbox.stub();
+ getter.onFirstCall().returns("en-US");
+ getter.onSecondCall().returns("fr");
+ sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter);
+ const provider = {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ };
+ await createRouterAndInit([provider]);
+ sandbox.spy(Router, "setState");
+ Router.loadMessagesFromAllProviders.resetHistory();
+
+ await Router._maybeUpdateL10nAttachment();
+
+ assert.calledWith(Router.setState, {
+ localeInUse: "fr",
+ providers: [
+ {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ lastUpdated: undefined,
+ errors: [],
+ },
+ ],
+ });
+ assert.calledOnce(Router.loadMessagesFromAllProviders);
+ });
+ it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => {
+ const getter = sandbox.stub();
+ getter.onFirstCall().returns("en-US");
+ getter.onSecondCall().returns("fr");
+ sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter);
+ const provider = {
+ id: "localProvider",
+ enabled: true,
+ type: "local",
+ };
+ await createRouterAndInit([provider]);
+ Router.loadMessagesFromAllProviders.resetHistory();
+ sandbox.spy(Router, "setState");
+
+ await Router._maybeUpdateL10nAttachment();
+
+ assert.notCalled(Router.setState);
+ assert.notCalled(Router.loadMessagesFromAllProviders);
+ });
+ });
+ describe("#observe", () => {
+ it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => {
+ sandbox.spy(CFRPageActions, "reloadL10n");
+
+ Router.observe("", "", USE_REMOTE_L10N_PREF);
+
+ assert.calledOnce(CFRPageActions.reloadL10n);
+ });
+ it("should not react to other pref changes", () => {
+ sandbox.spy(CFRPageActions, "reloadL10n");
+
+ Router.observe("", "", "foo");
+
+ assert.notCalled(CFRPageActions.reloadL10n);
+ });
+ });
+ describe("#loadAllMessageGroups", () => {
+ it("should disable the group if the pref is false", async () => {
+ sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false);
+ sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([
+ {
+ id: "provider-group",
+ enabled: true,
+ type: "remote",
+ userPreferences: ["cfrAddons"],
+ },
+ ]);
+ await Router.setState({
+ providers: [
+ {
+ id: "message-groups",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "provider-group");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", false);
+ });
+ it("should enable the group if at least one pref is true", async () => {
+ sandbox
+ .stub(ASRouterPreferences, "getUserPreference")
+ .withArgs("cfrAddons")
+ .returns(false)
+ .withArgs("cfrFeatures")
+ .returns(true);
+ sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([
+ {
+ id: "provider-group",
+ enabled: true,
+ type: "remote",
+ userPreferences: ["cfrAddons", "cfrFeatures"],
+ },
+ ]);
+ await Router.setState({
+ providers: [
+ {
+ id: "message-groups",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "provider-group");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", true);
+ });
+ it("should be keep the group disabled if disabled is true", async () => {
+ sandbox.stub(ASRouterPreferences, "getUserPreference").returns(true);
+ sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([
+ {
+ id: "provider-group",
+ enabled: false,
+ type: "remote",
+ userPreferences: ["cfrAddons"],
+ },
+ ]);
+ await Router.setState({
+ providers: [
+ {
+ id: "message-groups",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "provider-group");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", false);
+ });
+ it("should keep local groups unchanged if provider doesn't require an update", async () => {
+ sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false);
+ sandbox.stub(MessageLoaderUtils, "_loadDataForProvider");
+ await Router.setState({
+ groups: [
+ {
+ id: "cfr",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "cfr");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", true);
+ // Because it should not have updated
+ assert.notCalled(MessageLoaderUtils._loadDataForProvider);
+ });
+ it("should update local groups on pref change (no RS update)", async () => {
+ sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false);
+ sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false);
+ await Router.setState({
+ groups: [
+ {
+ id: "cfr",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ userPreferences: ["cfrAddons"],
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "cfr");
+
+ assert.ok(group);
+ // Pref changed, updated the group state
+ assert.propertyVal(group, "enabled", false);
+ });
+ });
+ describe("unblockAll", () => {
+ it("Clears the message block list and returns the state value", async () => {
+ await Router.setState({ messageBlockList: ["one", "two", "three"] });
+ assert.equal(Router.state.messageBlockList.length, 3);
+ const state = await Router.unblockAll();
+ assert.equal(Router.state.messageBlockList.length, 0);
+ assert.equal(state.messageBlockList.length, 0);
+ });
+ });
+ describe("#loadMessagesForProvider", () => {
+ it("should fetch messages from the ExperimentAPI", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["spotlight"],
+ };
+
+ await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.calledOnce(global.NimbusFeatures.spotlight.getAllVariables);
+ assert.calledOnce(global.ExperimentAPI.getExperimentMetaData);
+ assert.calledWithExactly(global.ExperimentAPI.getExperimentMetaData, {
+ featureId: "spotlight",
+ });
+ });
+ it("should handle the case of no experiments in the ExperimentAPI", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["infobar"],
+ };
+
+ global.ExperimentAPI.getExperiment.returns(null);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.lengthOf(result.messages, 0);
+ });
+ it("should normally load ExperimentAPI messages", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["infobar"],
+ };
+ const enrollment = {
+ branch: {
+ slug: "branch01",
+ infobar: {
+ featureId: "infobar",
+ value: { id: "id01", trigger: { id: "openURL" } },
+ },
+ },
+ };
+
+ global.NimbusFeatures.infobar.getAllVariables.returns(
+ enrollment.branch.infobar.value
+ );
+ global.ExperimentAPI.getExperimentMetaData.returns({
+ branch: { slug: enrollment.branch.slug },
+ });
+ global.ExperimentAPI.getAllBranches.returns([
+ enrollment.branch,
+ {
+ slug: "control",
+ infobar: {
+ featureId: "infobar",
+ value: null,
+ },
+ },
+ ]);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.lengthOf(result.messages, 1);
+ });
+ it("should skip disabled features and not load the messages", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["cfr"],
+ };
+
+ global.NimbusFeatures.cfr.getAllVariables.returns(null);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.lengthOf(result.messages, 0);
+ });
+ it("should fetch branches with trigger", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["cfr"],
+ };
+ const enrollment = {
+ slug: "exp01",
+ branch: {
+ slug: "branch01",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id01", trigger: { id: "openURL" } },
+ },
+ },
+ };
+
+ global.NimbusFeatures.cfr.getAllVariables.returns(
+ enrollment.branch.cfr.value
+ );
+ global.ExperimentAPI.getExperimentMetaData.returns({
+ slug: enrollment.slug,
+ active: true,
+ branch: { slug: enrollment.branch.slug },
+ });
+ global.ExperimentAPI.getAllBranches.resolves([
+ enrollment.branch,
+ {
+ slug: "branch02",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id02", trigger: { id: "openURL" } },
+ },
+ },
+ {
+ // This branch should not be loaded as it doesn't have the trigger
+ slug: "branch03",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id03" },
+ },
+ },
+ ]);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.equal(result.messages.length, 2);
+ assert.equal(result.messages[0].id, "id01");
+ assert.equal(result.messages[1].id, "id02");
+ assert.equal(result.messages[1].experimentSlug, "exp01");
+ assert.equal(result.messages[1].branchSlug, "branch02");
+ assert.deepEqual(result.messages[1].forReachEvent, {
+ sent: false,
+ group: "cfr",
+ });
+ });
+ it("should fetch branches with trigger even if enrolled branch is disabled", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["cfr"],
+ };
+ const enrollment = {
+ slug: "exp01",
+ branch: {
+ slug: "branch01",
+ cfr: {
+ featureId: "cfr",
+ value: {},
+ },
+ },
+ };
+
+ // Nedds to match the `featureIds` value to return an enrollment
+ // for that feature
+ global.NimbusFeatures.cfr.getAllVariables.returns(
+ enrollment.branch.cfr.value
+ );
+ global.ExperimentAPI.getExperimentMetaData.returns({
+ slug: enrollment.slug,
+ active: true,
+ branch: { slug: enrollment.branch.slug },
+ });
+ global.ExperimentAPI.getAllBranches.resolves([
+ enrollment.branch,
+ {
+ slug: "branch02",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id02", trigger: { id: "openURL" } },
+ },
+ },
+ {
+ // This branch should not be loaded as it doesn't have the trigger
+ slug: "branch03",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id03" },
+ },
+ },
+ ]);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.equal(result.messages.length, 1);
+ assert.equal(result.messages[0].id, "id02");
+ assert.equal(result.messages[0].experimentSlug, "exp01");
+ assert.equal(result.messages[0].branchSlug, "branch02");
+ assert.deepEqual(result.messages[0].forReachEvent, {
+ sent: false,
+ group: "cfr",
+ });
+ });
+ });
+ describe("#_remoteSettingsLoader", () => {
+ let provider;
+ let spy;
+ beforeEach(() => {
+ provider = {
+ id: "cfr",
+ collection: "cfr",
+ };
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([{ id: "message_1" }]);
+ spy = sandbox.spy();
+ global.Downloader.prototype.downloadToDisk = spy;
+ });
+ it("should be called with the expected dir path", async () => {
+ const dlSpy = sandbox.spy(global, "Downloader");
+
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.calledWith(
+ dlSpy,
+ "main",
+ "ms-language-packs",
+ "browser",
+ "newtab"
+ );
+ });
+ it("should allow fetch for known locales", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.calledOnce(spy);
+ });
+ it("should fallback to 'en-US' for locale 'und' ", async () => {
+ sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "und");
+ const getRecordSpy = sandbox.spy(
+ global.KintoHttpClient.prototype,
+ "getRecord"
+ );
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.ok(getRecordSpy.args[0][0].includes("en-US"));
+ assert.calledOnce(spy);
+ });
+ it("should fallback to 'ja-JP-mac' for locale 'ja-JP-macos'", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "ja-JP-macos");
+ const getRecordSpy = sandbox.spy(
+ global.KintoHttpClient.prototype,
+ "getRecord"
+ );
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.ok(getRecordSpy.args[0][0].includes("ja-JP-mac"));
+ assert.calledOnce(spy);
+ });
+ it("should not allow fetch for unsupported locales", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "unkown");
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.notCalled(spy);
+ });
+ });
+ describe("#resetMessageState", () => {
+ it("should reset all message impressions", async () => {
+ await Router.setState({
+ messages: [{ id: "1" }, { id: "2" }],
+ });
+ await Router.setState({
+ messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] },
+ }); // Add impressions for test messages
+ let impressions = Object.values(Router.state.messageImpressions);
+ assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions
+
+ Router.resetMessageState();
+ impressions = Object.values(Router.state.messageImpressions);
+
+ assert.isEmpty(impressions.filter(i => i.length)); // Both messages now have zero impressions
+ assert.calledWithExactly(Router._storage.set, "messageImpressions", {
+ 1: [],
+ 2: [],
+ });
+ });
+ });
+ describe("#resetGroupsState", () => {
+ it("should reset all group impressions", async () => {
+ await Router.setState({
+ groups: [{ id: "1" }, { id: "2" }],
+ });
+ await Router.setState({
+ groupImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] },
+ }); // Add impressions for test groups
+ let impressions = Object.values(Router.state.groupImpressions);
+ assert.equal(impressions.filter(i => i.length).length, 2); // Both groups have impressions
+
+ Router.resetGroupsState();
+ impressions = Object.values(Router.state.groupImpressions);
+
+ assert.isEmpty(impressions.filter(i => i.length)); // Both groups now have zero impressions
+ assert.calledWithExactly(Router._storage.set, "groupImpressions", {
+ 1: [],
+ 2: [],
+ });
+ });
+ });
+ describe("#resetScreenImpressions", () => {
+ it("should reset all screen impressions", async () => {
+ await Router.setState({ screenImpressions: { 1: 1, 2: 2 } });
+ let impressions = Object.values(Router.state.screenImpressions);
+ assert.equal(impressions.filter(i => i).length, 2); // Both screens have impressions
+
+ Router.resetScreenImpressions();
+ impressions = Object.values(Router.state.screenImpressions);
+
+ assert.isEmpty(impressions.filter(i => i)); // Both screens now have zero impressions
+ assert.calledWithExactly(Router._storage.set, "screenImpressions", {});
+ });
+ });
+ describe("#editState", () => {
+ it("should update message impressions", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true);
+ await Router.setState({ messages: [{ id: "1" }, { id: "2" }] });
+ await Router.setState({
+ messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] },
+ });
+ let impressions = Object.values(Router.state.messageImpressions);
+ assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions
+
+ Router.editState("messageImpressions", {
+ 1: [],
+ 2: [],
+ 3: [0, 1, 2],
+ });
+
+ // The original messages now have zero impressions
+ assert.isEmpty(Router.state.messageImpressions["1"]);
+ assert.isEmpty(Router.state.messageImpressions["2"]);
+ // A new impression array was added for the new message
+ assert.equal(Router.state.messageImpressions["3"].length, 3);
+ assert.calledWithExactly(Router._storage.set, "messageImpressions", {
+ 1: [],
+ 2: [],
+ 3: [0, 1, 2],
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ASRouterChild.test.js b/browser/components/asrouter/tests/unit/ASRouterChild.test.js
new file mode 100644
index 0000000000..41fdd79ea2
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouterChild.test.js
@@ -0,0 +1,71 @@
+/*eslint max-nested-callbacks: ["error", 10]*/
+import { ASRouterChild } from "actors/ASRouterChild.sys.mjs";
+import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("ASRouterChild", () => {
+ let asRouterChild = null;
+ let globals = null;
+ let overrider = null;
+ let sandbox = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = {
+ Cu: {
+ cloneInto: sandbox.stub().returns(Promise.resolve()),
+ },
+ };
+ overrider = new GlobalOverrider();
+ overrider.set(globals);
+ asRouterChild = new ASRouterChild();
+ asRouterChild.telemetry = {
+ sendTelemetry: sandbox.stub(),
+ };
+ sandbox.stub(asRouterChild, "sendAsyncMessage");
+ sandbox.stub(asRouterChild, "sendQuery").returns(Promise.resolve());
+ });
+ afterEach(() => {
+ sandbox.restore();
+ overrider.restore();
+ asRouterChild = null;
+ });
+ describe("asRouterMessage", () => {
+ describe("uses sendAsyncMessage for types that don't need an async response", () => {
+ [
+ msg.DISABLE_PROVIDER,
+ msg.ENABLE_PROVIDER,
+ msg.EXPIRE_QUERY_CACHE,
+ msg.FORCE_WHATSNEW_PANEL,
+ msg.IMPRESSION,
+ msg.RESET_PROVIDER_PREF,
+ msg.SET_PROVIDER_USER_PREF,
+ msg.USER_ACTION,
+ ].forEach(type => {
+ it(`type ${type}`, () => {
+ asRouterChild.asRouterMessage({
+ type,
+ data: {
+ something: 1,
+ },
+ });
+ sandbox.assert.calledOnce(asRouterChild.sendAsyncMessage);
+ sandbox.assert.calledWith(asRouterChild.sendAsyncMessage, type, {
+ something: 1,
+ });
+ });
+ });
+ });
+ // Some legacy privileged extensions still send this legacy NEWTAB_MESSAGE_REQUEST
+ // action type. We simply
+ it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => {
+ assert.doesNotThrow(async () => {
+ let result = await asRouterChild.asRouterMessage({
+ type: "NEWTAB_MESSAGE_REQUEST",
+ data: {},
+ });
+ sandbox.assert.deepEqual(result, {});
+ sandbox.assert.notCalled(asRouterChild.sendAsyncMessage);
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js
new file mode 100644
index 0000000000..664b685881
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js
@@ -0,0 +1,153 @@
+/*eslint max-nested-callbacks: ["error", 10]*/
+import { ASRouterNewTabHook } from "modules/ASRouterNewTabHook.sys.mjs";
+
+describe("ASRouterNewTabHook", () => {
+ let sandbox = null;
+ let initParams = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ initParams = {
+ router: {
+ init: sandbox.stub().callsFake(() => {
+ // Fake the initialization
+ initParams.router.initialized = true;
+ }),
+ uninit: sandbox.stub(),
+ },
+ messageHandler: {
+ handleCFRAction: {},
+ handleTelemetry: {},
+ },
+ createStorage: () => Promise.resolve({}),
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ describe("ASRouterNewTabHook", () => {
+ describe("getInstance", () => {
+ it("awaits createInstance and router init before returning instance", async () => {
+ const getInstanceCall = sandbox.spy();
+ const waitForInstance =
+ ASRouterNewTabHook.getInstance().then(getInstanceCall);
+ await ASRouterNewTabHook.createInstance(initParams);
+ await waitForInstance;
+ assert.callOrder(initParams.router.init, getInstanceCall);
+ });
+ });
+ describe("createInstance", () => {
+ it("calls router init", async () => {
+ await ASRouterNewTabHook.createInstance(initParams);
+ assert.calledOnce(initParams.router.init);
+ });
+ it("only calls router init once", async () => {
+ initParams.router.init.callsFake(() => {
+ initParams.router.initialized = true;
+ });
+ await ASRouterNewTabHook.createInstance(initParams);
+ await ASRouterNewTabHook.createInstance(initParams);
+ assert.calledOnce(initParams.router.init);
+ });
+ });
+ describe("destroy", () => {
+ it("disconnects new tab, uninits ASRouter, and destroys instance", async () => {
+ await ASRouterNewTabHook.createInstance(initParams);
+ const instance = await ASRouterNewTabHook.getInstance();
+ const destroy = instance.destroy.bind(instance);
+ sandbox.stub(instance, "destroy").callsFake(destroy);
+ ASRouterNewTabHook.destroy();
+ assert.calledOnce(initParams.router.uninit);
+ assert.calledOnce(instance.destroy);
+ assert.isNotNull(instance);
+ assert.isNull(instance._newTabMessageHandler);
+ });
+ });
+ describe("instance", () => {
+ let routerParams = null;
+ let messageHandler = null;
+ let instance = null;
+ beforeEach(async () => {
+ messageHandler = {
+ clearChildMessages: sandbox.stub().resolves(),
+ clearChildProviders: sandbox.stub().resolves(),
+ updateAdminState: sandbox.stub().resolves(),
+ };
+ initParams.router.init.callsFake(params => {
+ routerParams = params;
+ });
+ await ASRouterNewTabHook.createInstance(initParams);
+ instance = await ASRouterNewTabHook.getInstance();
+ });
+ describe("connect", () => {
+ it("before connection messageHandler methods are not called", async () => {
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["test_provider"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.notCalled(messageHandler.clearChildMessages);
+ assert.notCalled(messageHandler.clearChildProviders);
+ assert.notCalled(messageHandler.updateAdminState);
+ });
+ it("after connect updateAdminState and clearChildMessages calls are forwarded to handler", async () => {
+ instance.connect(messageHandler);
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["test_provider"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.called(messageHandler.clearChildMessages);
+ assert.called(messageHandler.clearChildProviders);
+ assert.called(messageHandler.updateAdminState);
+ });
+ it("calls from before connection are dropped", async () => {
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["test_provider"]);
+ routerParams.updateAdminState({ messages: {} });
+ instance.connect(messageHandler);
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["test_provider"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.calledOnce(messageHandler.clearChildMessages);
+ assert.calledOnce(messageHandler.clearChildProviders);
+ assert.calledOnce(messageHandler.updateAdminState);
+ });
+ });
+ describe("disconnect", () => {
+ it("calls after disconnect are dropped", async () => {
+ instance.connect(messageHandler);
+ instance.disconnect();
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["test_provider"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.notCalled(messageHandler.clearChildMessages);
+ assert.notCalled(messageHandler.clearChildProviders);
+ assert.notCalled(messageHandler.updateAdminState);
+ });
+ it("only calls from when there is a connection are forwarded", async () => {
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["foo"]);
+ routerParams.updateAdminState({ messages: {} });
+ instance.connect(messageHandler);
+ routerParams.clearChildMessages([200]);
+ routerParams.clearChildProviders(["bar"]);
+ routerParams.updateAdminState({
+ messages: {
+ data: "accept",
+ },
+ });
+ instance.disconnect();
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["foo"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.calledOnce(messageHandler.clearChildMessages);
+ assert.calledOnce(messageHandler.clearChildProviders);
+ assert.calledOnce(messageHandler.updateAdminState);
+ assert.calledWith(messageHandler.clearChildMessages, [200]);
+ assert.calledWith(messageHandler.clearChildProviders, ["bar"]);
+ assert.calledWith(messageHandler.updateAdminState, {
+ messages: {
+ data: "accept",
+ },
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ASRouterParent.test.js b/browser/components/asrouter/tests/unit/ASRouterParent.test.js
new file mode 100644
index 0000000000..0358b1261c
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouterParent.test.js
@@ -0,0 +1,83 @@
+import { ASRouterParent } from "actors/ASRouterParent.sys.mjs";
+import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs";
+
+describe("ASRouterParent", () => {
+ let asRouterParent = null;
+ let sandbox = null;
+ let handleMessage = null;
+ let tabs = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ handleMessage = sandbox.stub().resolves("handle-message-result");
+ ASRouterParent.nextTabId = 1;
+ const methods = {
+ destroy: sandbox.stub(),
+ size: 1,
+ messageAll: sandbox.stub().resolves(),
+ registerActor: sandbox.stub(),
+ unregisterActor: sandbox.stub(),
+ loadingMessageHandler: Promise.resolve({
+ handleMessage,
+ }),
+ };
+ tabs = {
+ methods,
+ factory: sandbox.stub().returns(methods),
+ };
+ asRouterParent = new ASRouterParent({ tabsFactory: tabs.factory });
+ ASRouterParent.tabs = tabs.methods;
+ asRouterParent.browsingContext = {
+ embedderElement: {
+ getAttribute: () => true,
+ },
+ };
+ asRouterParent.tabId = ASRouterParent.nextTabId;
+ });
+ afterEach(() => {
+ sandbox.restore();
+ asRouterParent = null;
+ });
+ describe("actorCreated", () => {
+ it("after ASRouterTabs is instanced", () => {
+ asRouterParent.actorCreated();
+ assert.equal(asRouterParent.tabId, 2);
+ assert.notCalled(tabs.factory);
+ assert.calledOnce(tabs.methods.registerActor);
+ });
+ it("before ASRouterTabs is instanced", () => {
+ ASRouterParent.tabs = null;
+ ASRouterParent.nextTabId = 0;
+ asRouterParent.actorCreated();
+ assert.calledOnce(tabs.factory);
+ assert.isNotNull(ASRouterParent.tabs);
+ assert.equal(asRouterParent.tabId, 1);
+ });
+ });
+ describe("didDestroy", () => {
+ it("one still remains", () => {
+ ASRouterParent.tabs.size = 1;
+ asRouterParent.didDestroy();
+ assert.isNotNull(ASRouterParent.tabs);
+ assert.calledOnce(ASRouterParent.tabs.unregisterActor);
+ assert.notCalled(ASRouterParent.tabs.destroy);
+ });
+ it("none remain", () => {
+ ASRouterParent.tabs.size = 0;
+ const tabsCopy = ASRouterParent.tabs;
+ asRouterParent.didDestroy();
+ assert.isNull(ASRouterParent.tabs);
+ assert.calledOnce(tabsCopy.unregisterActor);
+ assert.calledOnce(tabsCopy.destroy);
+ });
+ });
+ describe("receiveMessage", async () => {
+ it("passes call to parentProcessMessageHandler and returns the result from handler", async () => {
+ const result = await asRouterParent.receiveMessage({
+ name: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id: 1 },
+ });
+ assert.calledOnce(handleMessage);
+ assert.equal(result, "handle-message-result");
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js
new file mode 100644
index 0000000000..7bfec3e099
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js
@@ -0,0 +1,428 @@
+import { ASRouterParentProcessMessageHandler } from "modules/ASRouterParentProcessMessageHandler.sys.mjs";
+import { _ASRouter } from "modules/ASRouter.sys.mjs";
+import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs";
+
+describe("ASRouterParentProcessMessageHandler", () => {
+ let handler = null;
+ let sandbox = null;
+ let config = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ const returnValue = { value: 1 };
+ const router = new _ASRouter();
+ [
+ "addImpression",
+ "evaluateExpression",
+ "forceAttribution",
+ "forceWNPanel",
+ "closeWNPanel",
+ "forcePBWindow",
+ "resetGroupsState",
+ "resetMessageState",
+ "resetScreenImpressions",
+ "editState",
+ ].forEach(method => sandbox.stub(router, `${method}`).resolves());
+ [
+ "blockMessageById",
+ "loadMessagesFromAllProviders",
+ "sendTriggerMessage",
+ "routeCFRMessage",
+ "setMessageById",
+ "updateTargetingParameters",
+ "unblockMessageById",
+ "unblockAll",
+ ].forEach(method =>
+ sandbox.stub(router, `${method}`).resolves(returnValue)
+ );
+ router._storage = {
+ set: sandbox.stub().resolves(),
+ get: sandbox.stub().resolves(),
+ };
+ sandbox.stub(router, "setState").callsFake(callback => {
+ if (typeof callback === "function") {
+ callback({
+ messageBlockList: [
+ {
+ id: 0,
+ },
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ },
+ {
+ id: 3,
+ },
+ {
+ id: 4,
+ },
+ ],
+ });
+ }
+ return Promise.resolve(returnValue);
+ });
+ const preferences = {
+ enableOrDisableProvider: sandbox.stub(),
+ resetProviderPref: sandbox.stub(),
+ setUserPreference: sandbox.stub(),
+ };
+ const specialMessageActions = {
+ handleAction: sandbox.stub(),
+ };
+ const queryCache = {
+ expireAll: sandbox.stub(),
+ };
+ const sendTelemetry = sandbox.stub();
+ config = {
+ router,
+ preferences,
+ specialMessageActions,
+ queryCache,
+ sendTelemetry,
+ };
+ handler = new ASRouterParentProcessMessageHandler(config);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ handler = null;
+ config = null;
+ });
+ describe("constructor", () => {
+ it("does not throw", () => {
+ assert.isNotNull(handler);
+ assert.isNotNull(config);
+ });
+ });
+ describe("handleCFRAction", () => {
+ it("non-telemetry type isn't sent to telemetry", () => {
+ handler.handleCFRAction({
+ type: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id: 1 },
+ });
+ assert.notCalled(config.sendTelemetry);
+ assert.calledOnce(config.router.blockMessageById);
+ });
+ it("passes browser to handleMessage", async () => {
+ await handler.handleCFRAction(
+ {
+ type: msg.USER_ACTION,
+ data: { id: 1 },
+ },
+ { ownerGlobal: {} }
+ );
+ assert.notCalled(config.sendTelemetry);
+ assert.calledOnce(config.specialMessageActions.handleAction);
+ assert.calledWith(
+ config.specialMessageActions.handleAction,
+ { id: 1 },
+ { ownerGlobal: {} }
+ );
+ });
+ [
+ msg.AS_ROUTER_TELEMETRY_USER_EVENT,
+ msg.TOOLBAR_BADGE_TELEMETRY,
+ msg.TOOLBAR_PANEL_TELEMETRY,
+ msg.MOMENTS_PAGE_TELEMETRY,
+ msg.DOORHANGER_TELEMETRY,
+ ].forEach(type => {
+ it(`telemetry type "${type}" is sent to telemetry`, () => {
+ handler.handleCFRAction({
+ type,
+ data: { id: 1 },
+ });
+ assert.calledOnce(config.sendTelemetry);
+ assert.notCalled(config.router.blockMessageById);
+ });
+ });
+ });
+ describe("#handleMessage", () => {
+ it("#default: should throw for unknown msg types", () => {
+ handler.handleMessage("err").then(
+ () => assert.fail("It should not succeed"),
+ () => assert.ok(true)
+ );
+ });
+ describe("#AS_ROUTER_TELEMETRY_USER_EVENT", () => {
+ it("should route AS_ROUTER_TELEMETRY_USER_EVENT to handleTelemetry", async () => {
+ const data = { data: "foo" };
+ await handler.handleMessage(msg.AS_ROUTER_TELEMETRY_USER_EVENT, data);
+
+ assert.calledOnce(handler.handleTelemetry);
+ assert.calledWithExactly(handler.handleTelemetry, {
+ type: msg.AS_ROUTER_TELEMETRY_USER_EVENT,
+ data,
+ });
+ });
+ });
+ describe("BLOCK_MESSAGE_BY_ID action", () => {
+ it("with preventDismiss returns false", async () => {
+ const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, {
+ id: 1,
+ preventDismiss: true,
+ });
+ assert.calledOnce(config.router.blockMessageById);
+ assert.isFalse(result);
+ });
+ it("by default returns true", async () => {
+ const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, {
+ id: 1,
+ });
+ assert.calledOnce(config.router.blockMessageById);
+ assert.isTrue(result);
+ });
+ });
+ describe("USER_ACTION action", () => {
+ it("default calls SpecialMessageActions.handleAction", async () => {
+ await handler.handleMessage(
+ msg.USER_ACTION,
+ {
+ type: "SOMETHING",
+ },
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.specialMessageActions.handleAction);
+ assert.calledWith(
+ config.specialMessageActions.handleAction,
+ { type: "SOMETHING" },
+ { ownerGlobal: {} }
+ );
+ });
+ });
+ describe("IMPRESSION action", () => {
+ it("default calls addImpression", () => {
+ handler.handleMessage(msg.IMPRESSION, {
+ id: 1,
+ });
+ assert.calledOnce(config.router.addImpression);
+ });
+ });
+ describe("TRIGGER action", () => {
+ it("default calls sendTriggerMessage and returns state", async () => {
+ const result = await handler.handleMessage(
+ msg.TRIGGER,
+ {
+ trigger: { stuff: {} },
+ },
+ { id: 100, browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.sendTriggerMessage);
+ assert.calledWith(config.router.sendTriggerMessage, {
+ stuff: {},
+ tabId: 100,
+ browser: { ownerGlobal: {} },
+ });
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("ADMIN_CONNECT_STATE action", () => {
+ it("with endpoint url calls loadMessagesFromAllProviders, and returns state", async () => {
+ const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE, {
+ endpoint: {
+ url: "test",
+ },
+ });
+ assert.calledOnce(config.router.loadMessagesFromAllProviders);
+ assert.deepEqual(result, { value: 1 });
+ });
+ it("default returns state", async () => {
+ const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE);
+ assert.calledOnce(config.router.updateTargetingParameters);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("UNBLOCK_MESSAGE_BY_ID action", () => {
+ it("default calls unblockMessageById", async () => {
+ const result = await handler.handleMessage(msg.UNBLOCK_MESSAGE_BY_ID, {
+ id: 1,
+ });
+ assert.calledOnce(config.router.unblockMessageById);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("UNBLOCK_ALL action", () => {
+ it("default calls unblockAll", async () => {
+ const result = await handler.handleMessage(msg.UNBLOCK_ALL);
+ assert.calledOnce(config.router.unblockAll);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("BLOCK_BUNDLE action", () => {
+ it("default calls unblockMessageById", async () => {
+ const result = await handler.handleMessage(msg.BLOCK_BUNDLE, {
+ bundle: [
+ {
+ id: 8,
+ },
+ {
+ id: 13,
+ },
+ ],
+ });
+ assert.calledOnce(config.router.blockMessageById);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("UNBLOCK_BUNDLE action", () => {
+ it("default calls setState", async () => {
+ const result = await handler.handleMessage(msg.UNBLOCK_BUNDLE, {
+ bundle: [
+ {
+ id: 1,
+ },
+ {
+ id: 3,
+ },
+ ],
+ });
+ assert.calledOnce(config.router.setState);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("DISABLE_PROVIDER action", () => {
+ it("default calls ASRouterPreferences.enableOrDisableProvider", () => {
+ handler.handleMessage(msg.DISABLE_PROVIDER, {});
+ assert.calledOnce(config.preferences.enableOrDisableProvider);
+ });
+ });
+ describe("ENABLE_PROVIDER action", () => {
+ it("default calls ASRouterPreferences.enableOrDisableProvider", () => {
+ handler.handleMessage(msg.ENABLE_PROVIDER, {});
+ assert.calledOnce(config.preferences.enableOrDisableProvider);
+ });
+ });
+ describe("EVALUATE_JEXL_EXPRESSION action", () => {
+ it("default calls evaluateExpression", () => {
+ handler.handleMessage(msg.EVALUATE_JEXL_EXPRESSION, {});
+ assert.calledOnce(config.router.evaluateExpression);
+ });
+ });
+ describe("EXPIRE_QUERY_CACHE action", () => {
+ it("default calls QueryCache.expireAll", () => {
+ handler.handleMessage(msg.EXPIRE_QUERY_CACHE);
+ assert.calledOnce(config.queryCache.expireAll);
+ });
+ });
+ describe("FORCE_ATTRIBUTION action", () => {
+ it("default calls forceAttribution", () => {
+ handler.handleMessage(msg.FORCE_ATTRIBUTION, {});
+ assert.calledOnce(config.router.forceAttribution);
+ });
+ });
+ describe("FORCE_WHATSNEW_PANEL action", () => {
+ it("default calls forceWNPanel", () => {
+ handler.handleMessage(
+ msg.FORCE_WHATSNEW_PANEL,
+ {},
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.forceWNPanel);
+ assert.calledWith(config.router.forceWNPanel, { ownerGlobal: {} });
+ });
+ });
+ describe("CLOSE_WHATSNEW_PANEL action", () => {
+ it("default calls closeWNPanel", () => {
+ handler.handleMessage(
+ msg.CLOSE_WHATSNEW_PANEL,
+ {},
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.closeWNPanel);
+ assert.calledWith(config.router.closeWNPanel, { ownerGlobal: {} });
+ });
+ });
+ describe("FORCE_PRIVATE_BROWSING_WINDOW action", () => {
+ it("default calls forcePBWindow", () => {
+ handler.handleMessage(
+ msg.FORCE_PRIVATE_BROWSING_WINDOW,
+ {},
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.forcePBWindow);
+ assert.calledWith(config.router.forcePBWindow, { ownerGlobal: {} });
+ });
+ });
+ describe("MODIFY_MESSAGE_JSON action", () => {
+ it("default calls routeCFRMessage", async () => {
+ const result = await handler.handleMessage(
+ msg.MODIFY_MESSAGE_JSON,
+ {
+ content: {
+ text: "something",
+ },
+ },
+ { browser: { ownerGlobal: {} }, id: 100 }
+ );
+ assert.calledOnce(config.router.routeCFRMessage);
+ assert.calledWith(
+ config.router.routeCFRMessage,
+ { text: "something" },
+ { ownerGlobal: {} },
+ { content: { text: "something" } },
+ true
+ );
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("OVERRIDE_MESSAGE action", () => {
+ it("default calls setMessageById", async () => {
+ const result = await handler.handleMessage(
+ msg.OVERRIDE_MESSAGE,
+ {
+ id: 1,
+ },
+ { id: 100, browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.setMessageById);
+ assert.calledWith(config.router.setMessageById, { id: 1 }, true, {
+ ownerGlobal: {},
+ });
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("RESET_PROVIDER_PREF action", () => {
+ it("default calls ASRouterPreferences.resetProviderPref", () => {
+ handler.handleMessage(msg.RESET_PROVIDER_PREF);
+ assert.calledOnce(config.preferences.resetProviderPref);
+ });
+ });
+ describe("SET_PROVIDER_USER_PREF action", () => {
+ it("default calls ASRouterPreferences.setUserPreference", () => {
+ handler.handleMessage(msg.SET_PROVIDER_USER_PREF, {
+ id: 1,
+ value: true,
+ });
+ assert.calledOnce(config.preferences.setUserPreference);
+ assert.calledWith(config.preferences.setUserPreference, 1, true);
+ });
+ });
+ describe("RESET_GROUPS_STATE action", () => {
+ it("default calls resetGroupsState, loadMessagesFromAllProviders, and returns state", async () => {
+ const result = await handler.handleMessage(msg.RESET_GROUPS_STATE, {
+ property: "value",
+ });
+ assert.calledOnce(config.router.resetGroupsState);
+ assert.calledOnce(config.router.loadMessagesFromAllProviders);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("RESET_MESSAGE_STATE action", () => {
+ it("default calls resetMessageState", () => {
+ handler.handleMessage(msg.RESET_MESSAGE_STATE);
+ assert.calledOnce(config.router.resetMessageState);
+ });
+ });
+ describe("RESET_SCREEN_IMPRESSIONS action", () => {
+ it("default calls resetScreenImpressions", () => {
+ handler.handleMessage(msg.RESET_SCREEN_IMPRESSIONS);
+ assert.calledOnce(config.router.resetScreenImpressions);
+ });
+ });
+ describe("EDIT_STATE action", () => {
+ it("default calls editState with correct args", () => {
+ handler.handleMessage(msg.EDIT_STATE, { property: "value" });
+ assert.calledWith(config.router.editState, "property", "value");
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js
new file mode 100644
index 0000000000..a3fe1fc5c9
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js
@@ -0,0 +1,480 @@
+import {
+ _ASRouterPreferences,
+ ASRouterPreferences as ASRouterPreferencesSingleton,
+ TEST_PROVIDERS,
+} from "modules/ASRouterPreferences.sys.mjs";
+const FAKE_PROVIDERS = [{ id: "foo" }, { id: "bar" }];
+
+const PROVIDER_PREF_BRANCH =
+ "browser.newtabpage.activity-stream.asrouter.providers.";
+const DEVTOOLS_PREF =
+ "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled";
+const CFR_USER_PREF_ADDONS =
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons";
+const CFR_USER_PREF_FEATURES =
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features";
+
+/** NUMBER_OF_PREFS_TO_OBSERVE includes:
+ * 1. asrouter.providers. pref branch
+ * 2. asrouter.devtoolsEnabled
+ * 3. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons (user preference - cfr)
+ * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features (user preference - cfr)
+ * 5. services.sync.username
+ */
+const NUMBER_OF_PREFS_TO_OBSERVE = 5;
+
+describe("ASRouterPreferences", () => {
+ let ASRouterPreferences;
+ let sandbox;
+ let addObserverStub;
+ let stringPrefStub;
+ let boolPrefStub;
+ let resetStub;
+ let hasUserValueStub;
+ let childListStub;
+ let setStringPrefStub;
+
+ beforeEach(() => {
+ ASRouterPreferences = new _ASRouterPreferences();
+
+ sandbox = sinon.createSandbox();
+ addObserverStub = sandbox.stub(global.Services.prefs, "addObserver");
+ stringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+ resetStub = sandbox.stub(global.Services.prefs, "clearUserPref");
+ setStringPrefStub = sandbox.stub(global.Services.prefs, "setStringPref");
+ FAKE_PROVIDERS.forEach(provider => {
+ stringPrefStub
+ .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`)
+ .returns(JSON.stringify(provider));
+ });
+
+ boolPrefStub = sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .returns(false);
+
+ hasUserValueStub = sandbox
+ .stub(global.Services.prefs, "prefHasUserValue")
+ .returns(false);
+
+ childListStub = sandbox.stub(global.Services.prefs, "getChildList");
+ childListStub
+ .withArgs(PROVIDER_PREF_BRANCH)
+ .returns(
+ FAKE_PROVIDERS.map(provider => `${PROVIDER_PREF_BRANCH}${provider.id}`)
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ function getPrefNameForProvider(providerId) {
+ return `${PROVIDER_PREF_BRANCH}${providerId}`;
+ }
+
+ function setPrefForProvider(providerId, value) {
+ stringPrefStub
+ .withArgs(getPrefNameForProvider(providerId))
+ .returns(JSON.stringify(value));
+ }
+
+ it("ASRouterPreferences should be an instance of _ASRouterPreferences", () => {
+ assert.instanceOf(ASRouterPreferencesSingleton, _ASRouterPreferences);
+ });
+ describe("#init", () => {
+ it("should set ._initialized to true", () => {
+ ASRouterPreferences.init();
+ assert.isTrue(ASRouterPreferences._initialized);
+ });
+ it("should migrate the provider prefs", () => {
+ ASRouterPreferences.uninit();
+ // Should be migrated because they contain bucket and not collection
+ const MIGRATE_PROVIDERS = [
+ { id: "baz", bucket: "buk" },
+ { id: "qux", bucket: "buk" },
+ ];
+ // Should be cleared to defaults because it throws on setStringPref
+ const ERROR_PROVIDER = { id: "err", bucket: "buk" };
+ // Should not be migrated because, although modified, it lacks bucket
+ const MODIFIED_SAFE_PROVIDER = { id: "safe" };
+ const ALL_PROVIDERS = [
+ ...MIGRATE_PROVIDERS,
+ ...FAKE_PROVIDERS, // Should not be migrated because they're unmodified
+ MODIFIED_SAFE_PROVIDER,
+ ERROR_PROVIDER,
+ ];
+ // The migrator should attempt to read prefs for all of these providers
+ const TRY_PROVIDERS = [
+ ...MIGRATE_PROVIDERS,
+ MODIFIED_SAFE_PROVIDER,
+ ERROR_PROVIDER,
+ ];
+
+ // Update the full list of provider prefs
+ childListStub
+ .withArgs(PROVIDER_PREF_BRANCH)
+ .returns(
+ ALL_PROVIDERS.map(provider => getPrefNameForProvider(provider.id))
+ );
+ // Stub the pref values so the migrator can read them
+ ALL_PROVIDERS.forEach(provider => {
+ stringPrefStub
+ .withArgs(getPrefNameForProvider(provider.id))
+ .returns(JSON.stringify(provider));
+ });
+
+ // Consider these providers' prefs "modified"
+ TRY_PROVIDERS.forEach(provider => {
+ hasUserValueStub
+ .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`)
+ .returns(true);
+ });
+ // Spoof an error when trying to set the pref for this provider so we can
+ // test that the pref is gracefully reset on error
+ setStringPrefStub
+ .withArgs(getPrefNameForProvider(ERROR_PROVIDER.id))
+ .throws();
+
+ ASRouterPreferences.init();
+
+ // The migrator should have tried to check each pref for user modification
+ ALL_PROVIDERS.forEach(provider =>
+ assert.calledWith(hasUserValueStub, getPrefNameForProvider(provider.id))
+ );
+ // Test that we don't call getStringPref for providers that don't have a
+ // user-defined value
+ FAKE_PROVIDERS.forEach(provider =>
+ assert.neverCalledWith(
+ stringPrefStub,
+ getPrefNameForProvider(provider.id)
+ )
+ );
+ // But we do call it for providers that do have a user-defined value
+ TRY_PROVIDERS.forEach(provider =>
+ assert.calledWith(stringPrefStub, getPrefNameForProvider(provider.id))
+ );
+
+ // Test that we don't call setStringPref to migrate providers that don't
+ // have a bucket property
+ assert.neverCalledWith(
+ setStringPrefStub,
+ getPrefNameForProvider(MODIFIED_SAFE_PROVIDER.id)
+ );
+
+ /**
+ * For a given provider, return a sinon matcher that matches if the value
+ * looks like a migrated version of the original provider. Requires that:
+ * its id matches the original provider's id; it has no bucket; and its
+ * collection is set to the value of the original provider's bucket.
+ * @param {object} provider the provider object to compare to
+ * @returns {object} custom matcher object for sinon
+ */
+ function providerJsonMatches(provider) {
+ return sandbox.match(migrated => {
+ const parsed = JSON.parse(migrated);
+ return (
+ parsed.id === provider.id &&
+ !("bucket" in parsed) &&
+ parsed.collection === provider.bucket
+ );
+ });
+ }
+
+ // Test that we call setStringPref to migrate providers that have a bucket
+ // property and don't have a collection property
+ MIGRATE_PROVIDERS.forEach(provider =>
+ assert.calledWith(
+ setStringPrefStub,
+ getPrefNameForProvider(provider.id),
+ providerJsonMatches(provider) // Verify the migrated pref value
+ )
+ );
+
+ // Test that we clear the pref for providers that throw when we try to
+ // read or write them
+ assert.calledWith(resetStub, getPrefNameForProvider(ERROR_PROVIDER.id));
+ });
+ it(`should set ${NUMBER_OF_PREFS_TO_OBSERVE} observers and not re-initialize if already initialized`, () => {
+ ASRouterPreferences.init();
+ assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE);
+ ASRouterPreferences.init();
+ ASRouterPreferences.init();
+ assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE);
+ });
+ });
+ describe("#uninit", () => {
+ it("should set ._initialized to false", () => {
+ ASRouterPreferences.init();
+ ASRouterPreferences.uninit();
+ assert.isFalse(ASRouterPreferences._initialized);
+ });
+ it("should clear cached values for ._initialized, .devtoolsEnabled", () => {
+ ASRouterPreferences.init();
+ // trigger caching
+ // eslint-disable-next-line no-unused-vars
+ const result = [
+ ASRouterPreferences.providers,
+ ASRouterPreferences.devtoolsEnabled,
+ ];
+ assert.isNotNull(
+ ASRouterPreferences._providers,
+ "providers should not be null"
+ );
+ assert.isNotNull(
+ ASRouterPreferences._devtoolsEnabled,
+ "devtolosEnabled should not be null"
+ );
+
+ ASRouterPreferences.uninit();
+ assert.isNull(ASRouterPreferences._providers);
+ assert.isNull(ASRouterPreferences._devtoolsEnabled);
+ });
+ it("should clear all listeners and remove observers (only once)", () => {
+ const removeStub = sandbox.stub(global.Services.prefs, "removeObserver");
+ ASRouterPreferences.init();
+ ASRouterPreferences.addListener(() => {});
+ ASRouterPreferences.addListener(() => {});
+ assert.equal(ASRouterPreferences._callbacks.size, 2);
+ ASRouterPreferences.uninit();
+ // Tests to make sure we don't remove observers that weren't set
+ ASRouterPreferences.uninit();
+
+ assert.callCount(removeStub, NUMBER_OF_PREFS_TO_OBSERVE);
+ assert.calledWith(removeStub, PROVIDER_PREF_BRANCH);
+ assert.calledWith(removeStub, DEVTOOLS_PREF);
+ assert.isEmpty(ASRouterPreferences._callbacks);
+ });
+ });
+ describe(".providers", () => {
+ it("should return the value the first time .providers is accessed", () => {
+ ASRouterPreferences.init();
+
+ const result = ASRouterPreferences.providers;
+ assert.deepEqual(result, FAKE_PROVIDERS);
+ // once per pref
+ assert.calledTwice(stringPrefStub);
+ });
+ it("should return the cached value the second time .providers is accessed", () => {
+ ASRouterPreferences.init();
+ const [, secondCall] = [
+ ASRouterPreferences.providers,
+ ASRouterPreferences.providers,
+ ];
+
+ assert.deepEqual(secondCall, FAKE_PROVIDERS);
+ // once per pref
+ assert.calledTwice(stringPrefStub);
+ });
+ it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => {
+ // Intentionally not initialized
+ const [firstCall, secondCall] = [
+ ASRouterPreferences.providers,
+ ASRouterPreferences.providers,
+ ];
+
+ assert.deepEqual(firstCall, FAKE_PROVIDERS);
+ assert.deepEqual(secondCall, FAKE_PROVIDERS);
+ assert.callCount(stringPrefStub, 4);
+ });
+ it("should skip the pref without throwing if a pref is not parsable", () => {
+ stringPrefStub.withArgs(`${PROVIDER_PREF_BRANCH}foo`).returns("not json");
+ ASRouterPreferences.init();
+
+ assert.deepEqual(ASRouterPreferences.providers, [{ id: "bar" }]);
+ });
+ it("should include TEST_PROVIDERS if devtools is turned on", () => {
+ boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true);
+ ASRouterPreferences.init();
+
+ assert.deepEqual(ASRouterPreferences.providers, [
+ ...TEST_PROVIDERS,
+ ...FAKE_PROVIDERS,
+ ]);
+ });
+ });
+ describe(".devtoolsEnabled", () => {
+ it("should read the pref the first time .devtoolsEnabled is accessed", () => {
+ ASRouterPreferences.init();
+
+ const result = ASRouterPreferences.devtoolsEnabled;
+ assert.deepEqual(result, false);
+ assert.calledOnce(boolPrefStub);
+ });
+ it("should return the cached value the second time .devtoolsEnabled is accessed", () => {
+ ASRouterPreferences.init();
+ const [, secondCall] = [
+ ASRouterPreferences.devtoolsEnabled,
+ ASRouterPreferences.devtoolsEnabled,
+ ];
+
+ assert.deepEqual(secondCall, false);
+ assert.calledOnce(boolPrefStub);
+ });
+ it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => {
+ // Intentionally not initialized
+ const [firstCall, secondCall] = [
+ ASRouterPreferences.devtoolsEnabled,
+ ASRouterPreferences.devtoolsEnabled,
+ ];
+
+ assert.deepEqual(firstCall, false);
+ assert.deepEqual(secondCall, false);
+ assert.calledTwice(boolPrefStub);
+ });
+ });
+ describe("#getAllUserPreferences", () => {
+ it("should return all user preferences", () => {
+ boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false);
+ boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true);
+ const result = ASRouterPreferences.getAllUserPreferences();
+ assert.deepEqual(result, {
+ cfrAddons: false,
+ cfrFeatures: true,
+ });
+ });
+ });
+ describe("#enableOrDisableProvider", () => {
+ it("should enable an existing provider if second param is true", () => {
+ setPrefForProvider("foo", { id: "foo", enabled: false });
+ assert.isFalse(ASRouterPreferences.providers[0].enabled);
+
+ ASRouterPreferences.enableOrDisableProvider("foo", true);
+
+ assert.calledWith(
+ setStringPrefStub,
+ getPrefNameForProvider("foo"),
+ JSON.stringify({ id: "foo", enabled: true })
+ );
+ });
+ it("should disable an existing provider if second param is false", () => {
+ setPrefForProvider("foo", { id: "foo", enabled: true });
+ assert.isTrue(ASRouterPreferences.providers[0].enabled);
+
+ ASRouterPreferences.enableOrDisableProvider("foo", false);
+
+ assert.calledWith(
+ setStringPrefStub,
+ getPrefNameForProvider("foo"),
+ JSON.stringify({ id: "foo", enabled: false })
+ );
+ });
+ it("should not throw if the id does not exist", () => {
+ assert.doesNotThrow(() => {
+ ASRouterPreferences.enableOrDisableProvider("does_not_exist", true);
+ });
+ });
+ it("should not throw if pref is not parseable", () => {
+ stringPrefStub
+ .withArgs(getPrefNameForProvider("foo"))
+ .returns("not valid");
+ assert.doesNotThrow(() => {
+ ASRouterPreferences.enableOrDisableProvider("foo", true);
+ });
+ });
+ });
+ describe("#setUserPreference", () => {
+ it("should do nothing if the pref doesn't exist", () => {
+ ASRouterPreferences.setUserPreference("foo", true);
+ assert.notCalled(boolPrefStub);
+ });
+ it("should set the given pref", () => {
+ const setStub = sandbox.stub(global.Services.prefs, "setBoolPref");
+ ASRouterPreferences.setUserPreference("cfrAddons", true);
+ assert.calledWith(setStub, CFR_USER_PREF_ADDONS, true);
+ });
+ });
+ describe("#resetProviderPref", () => {
+ it("should reset the pref and user prefs", () => {
+ ASRouterPreferences.resetProviderPref();
+ FAKE_PROVIDERS.forEach(provider => {
+ assert.calledWith(resetStub, getPrefNameForProvider(provider.id));
+ });
+ assert.calledWith(resetStub, CFR_USER_PREF_ADDONS);
+ assert.calledWith(resetStub, CFR_USER_PREF_FEATURES);
+ });
+ });
+ describe("observer, listeners", () => {
+ it("should invalidate .providers when the pref is changed", () => {
+ const testProvider = { id: "newstuff" };
+ const newProviders = [...FAKE_PROVIDERS, testProvider];
+
+ ASRouterPreferences.init();
+
+ assert.deepEqual(ASRouterPreferences.providers, FAKE_PROVIDERS);
+ stringPrefStub
+ .withArgs(getPrefNameForProvider(testProvider.id))
+ .returns(JSON.stringify(testProvider));
+ childListStub
+ .withArgs(PROVIDER_PREF_BRANCH)
+ .returns(
+ newProviders.map(provider => getPrefNameForProvider(provider.id))
+ );
+ ASRouterPreferences.observe(
+ null,
+ null,
+ getPrefNameForProvider(testProvider.id)
+ );
+
+ // Cache should be invalidated so we access the new value of the pref now
+ assert.deepEqual(ASRouterPreferences.providers, newProviders);
+ });
+ it("should invalidate .devtoolsEnabled and .providers when the pref is changed", () => {
+ ASRouterPreferences.init();
+
+ assert.isFalse(ASRouterPreferences.devtoolsEnabled);
+ boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true);
+ childListStub.withArgs(PROVIDER_PREF_BRANCH).returns([]);
+ ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
+
+ // Cache should be invalidated so we access the new value of the pref now
+ // Note that providers needs to be invalidated because devtools adds test content to it.
+ assert.isTrue(ASRouterPreferences.devtoolsEnabled);
+ assert.deepEqual(ASRouterPreferences.providers, TEST_PROVIDERS);
+ });
+ it("should call listeners added with .addListener", () => {
+ const callback1 = sinon.stub();
+ const callback2 = sinon.stub();
+ ASRouterPreferences.init();
+ ASRouterPreferences.addListener(callback1);
+ ASRouterPreferences.addListener(callback2);
+
+ ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo"));
+ assert.calledWith(callback1, getPrefNameForProvider("foo"));
+
+ ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
+ assert.calledWith(callback2, DEVTOOLS_PREF);
+ });
+ it("should not call listeners after they are removed with .removeListeners", () => {
+ const callback = sinon.stub();
+ ASRouterPreferences.init();
+ ASRouterPreferences.addListener(callback);
+
+ ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo"));
+ assert.calledWith(callback, getPrefNameForProvider("foo"));
+
+ callback.reset();
+ ASRouterPreferences.removeListener(callback);
+
+ ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
+ assert.notCalled(callback);
+ });
+ });
+ describe("#_transformPersonalizedCfrScores", () => {
+ it("should report JSON.parse errors", () => {
+ sandbox.stub(global.console, "error");
+
+ ASRouterPreferences._transformPersonalizedCfrScores("");
+
+ assert.calledOnce(global.console.error);
+ });
+ it("should return an object parsed from a string", () => {
+ const scores = { FOO: 3000, BAR: 4000 };
+ assert.deepEqual(
+ ASRouterPreferences._transformPersonalizedCfrScores(
+ JSON.stringify(scores)
+ ),
+ scores
+ );
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js
new file mode 100644
index 0000000000..610b488b47
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js
@@ -0,0 +1,574 @@
+import {
+ ASRouterTargeting,
+ CachedTargetingGetter,
+ getSortedMessages,
+ QueryCache,
+} from "modules/ASRouterTargeting.sys.mjs";
+import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs";
+import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+// Note that tests for the ASRouterTargeting environment can be found in
+// test/functional/mochitest/browser_asrouter_targeting.js
+
+describe("#CachedTargetingGetter", () => {
+ const sixHours = 6 * 60 * 60 * 1000;
+ let sandbox;
+ let clock;
+ let frecentStub;
+ let topsitesCache;
+ let globals;
+ let doesAppNeedPinStub;
+ let getAddonsByTypesStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ clock = sinon.useFakeTimers();
+ frecentStub = sandbox.stub(
+ global.NewTabUtils.activityStreamProvider,
+ "getTopFrecentSites"
+ );
+ topsitesCache = new CachedTargetingGetter("getTopFrecentSites");
+ globals = new GlobalOverrider();
+ globals.set(
+ "TargetingContext",
+ class {
+ static combineContexts(...args) {
+ return sinon.stub();
+ }
+
+ evalWithDefault(expr) {
+ return sinon.stub();
+ }
+ }
+ );
+ doesAppNeedPinStub = sandbox.stub().resolves();
+ getAddonsByTypesStub = sandbox.stub().resolves();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ clock.restore();
+ globals.restore();
+ });
+
+ it("should cache allow for optional getter argument", async () => {
+ let pinCachedGetter = new CachedTargetingGetter(
+ "doesAppNeedPin",
+ true,
+ undefined,
+ { doesAppNeedPin: doesAppNeedPinStub }
+ );
+ // Need to tick forward because Date.now() is stubbed
+ clock.tick(sixHours);
+
+ await pinCachedGetter.get();
+ await pinCachedGetter.get();
+ await pinCachedGetter.get();
+
+ // Called once; cached request
+ assert.calledOnce(doesAppNeedPinStub);
+
+ // Called with option argument
+ assert.calledWith(doesAppNeedPinStub, true);
+
+ // Expire and call again
+ clock.tick(sixHours);
+ await pinCachedGetter.get();
+
+ // Call goes through
+ assert.calledTwice(doesAppNeedPinStub);
+
+ let themesCachedGetter = new CachedTargetingGetter(
+ "getAddonsByTypes",
+ ["foo"],
+ undefined,
+ { getAddonsByTypes: getAddonsByTypesStub }
+ );
+
+ // Need to tick forward because Date.now() is stubbed
+ clock.tick(sixHours);
+
+ await themesCachedGetter.get();
+ await themesCachedGetter.get();
+ await themesCachedGetter.get();
+
+ // Called once; cached request
+ assert.calledOnce(getAddonsByTypesStub);
+
+ // Called with option argument
+ assert.calledWith(getAddonsByTypesStub, ["foo"]);
+
+ // Expire and call again
+ clock.tick(sixHours);
+ await themesCachedGetter.get();
+
+ // Call goes through
+ assert.calledTwice(getAddonsByTypesStub);
+ });
+
+ it("should only make a request every 6 hours", async () => {
+ frecentStub.resolves();
+ clock.tick(sixHours);
+
+ await topsitesCache.get();
+ await topsitesCache.get();
+
+ assert.calledOnce(
+ global.NewTabUtils.activityStreamProvider.getTopFrecentSites
+ );
+
+ clock.tick(sixHours);
+
+ await topsitesCache.get();
+
+ assert.calledTwice(
+ global.NewTabUtils.activityStreamProvider.getTopFrecentSites
+ );
+ });
+ it("throws when failing getter", async () => {
+ frecentStub.rejects(new Error("fake error"));
+ clock.tick(sixHours);
+
+ // assert.throws expect a function as the first parameter, try/catch is a
+ // workaround
+ let rejected = false;
+ try {
+ await topsitesCache.get();
+ } catch (e) {
+ rejected = true;
+ }
+
+ assert(rejected);
+ });
+ describe("sortMessagesByPriority", () => {
+ it("should sort messages in descending priority order", async () => {
+ const [m1, m2, m3 = { id: "m3" }] =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const checkMessageTargetingStub = sandbox
+ .stub(ASRouterTargeting, "checkMessageTargeting")
+ .resolves(false);
+ sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [
+ { ...m1, priority: 0 },
+ { ...m2, priority: 1 },
+ { ...m3, priority: 2 },
+ ],
+ trigger: "testing",
+ });
+
+ assert.equal(checkMessageTargetingStub.callCount, 3);
+
+ const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+ assert.equal(arg_m1.id, m3.id);
+
+ const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+ assert.equal(arg_m2.id, m2.id);
+
+ const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+ assert.equal(arg_m3.id, m1.id);
+ });
+ it("should sort messages with no priority last", async () => {
+ const [m1, m2, m3 = { id: "m3" }] =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const checkMessageTargetingStub = sandbox
+ .stub(ASRouterTargeting, "checkMessageTargeting")
+ .resolves(false);
+ sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [
+ { ...m1, priority: 0 },
+ { ...m2, priority: undefined },
+ { ...m3, priority: 2 },
+ ],
+ trigger: "testing",
+ });
+
+ assert.equal(checkMessageTargetingStub.callCount, 3);
+
+ const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+ assert.equal(arg_m1.id, m3.id);
+
+ const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+ assert.equal(arg_m2.id, m1.id);
+
+ const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+ assert.equal(arg_m3.id, m2.id);
+ });
+ it("should keep the order of messages with same priority unchanged", async () => {
+ const [m1, m2, m3 = { id: "m3" }] =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const checkMessageTargetingStub = sandbox
+ .stub(ASRouterTargeting, "checkMessageTargeting")
+ .resolves(false);
+ sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [
+ { ...m1, priority: 2, targeting: undefined, rank: 1 },
+ { ...m2, priority: undefined, targeting: undefined, rank: 1 },
+ { ...m3, priority: 2, targeting: undefined, rank: 1 },
+ ],
+ trigger: "testing",
+ });
+
+ assert.equal(checkMessageTargetingStub.callCount, 3);
+
+ const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+ assert.equal(arg_m1.id, m1.id);
+
+ const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+ assert.equal(arg_m2.id, m3.id);
+
+ const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+ assert.equal(arg_m3.id, m2.id);
+ });
+ });
+});
+describe("#isTriggerMatch", () => {
+ let trigger;
+ let message;
+ beforeEach(() => {
+ trigger = { id: "openURL" };
+ message = { id: "openURL" };
+ });
+ it("should return false if trigger and candidate ids are different", () => {
+ trigger.id = "trigger";
+ message.id = "message";
+
+ assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
+ assert.isTrue(
+ ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" })
+ );
+ });
+ it("should return true if the message we check doesn't have trigger params or patterns", () => {
+ // No params or patterns defined
+ assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
+ });
+ it("should return false if the trigger does not have params defined", () => {
+ message.params = {};
+
+ // trigger.param is undefined
+ assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
+ });
+ it("should return true if message params includes trigger host", () => {
+ message.params = ["mozilla.org"];
+ trigger.param = { host: "mozilla.org" };
+
+ assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
+ });
+ it("should return true if message params includes trigger param.type", () => {
+ message.params = ["ContentBlockingMilestone"];
+ trigger.param = { type: "ContentBlockingMilestone" };
+
+ assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
+ });
+ it("should return true if message params match trigger mask", () => {
+ // STATE_BLOCKED_FINGERPRINTING_CONTENT
+ message.params = [0x00000040];
+ trigger.param = { type: 538091584 };
+
+ assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
+ });
+});
+describe("#CacheListAttachedOAuthClients", () => {
+ const fourHours = 4 * 60 * 60 * 1000;
+ let sandbox;
+ let clock;
+ let fakeFxAccount;
+ let authClientsCache;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ clock = sinon.useFakeTimers();
+ fakeFxAccount = {
+ listAttachedOAuthClients: () => {},
+ };
+ globals.set("fxAccounts", fakeFxAccount);
+ authClientsCache = QueryCache.queries.ListAttachedOAuthClients;
+ sandbox
+ .stub(global.fxAccounts, "listAttachedOAuthClients")
+ .returns(Promise.resolve({}));
+ });
+
+ afterEach(() => {
+ authClientsCache.expire();
+ sandbox.restore();
+ clock.restore();
+ });
+
+ it("should only make additional request every 4 hours", async () => {
+ clock.tick(fourHours);
+
+ await authClientsCache.get();
+ assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
+
+ clock.tick(fourHours);
+ await authClientsCache.get();
+ assert.calledTwice(global.fxAccounts.listAttachedOAuthClients);
+ });
+
+ it("should not make additional request before 4 hours", async () => {
+ clock.tick(fourHours);
+
+ await authClientsCache.get();
+ assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
+
+ await authClientsCache.get();
+ assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
+ });
+});
+describe("ASRouterTargeting", () => {
+ let evalStub;
+ let sandbox;
+ let clock;
+ let globals;
+ let fakeTargetingContext;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ sandbox.replace(ASRouterTargeting, "Environment", {});
+ clock = sinon.useFakeTimers();
+ fakeTargetingContext = {
+ combineContexts: sandbox.stub(),
+ evalWithDefault: sandbox.stub().resolves(),
+ setTelemetrySource: sandbox.stub(),
+ };
+ globals = new GlobalOverrider();
+ globals.set(
+ "TargetingContext",
+ class {
+ static combineContexts(...args) {
+ return fakeTargetingContext.combineContexts.apply(sandbox, args);
+ }
+
+ setTelemetrySource(id) {
+ fakeTargetingContext.setTelemetrySource(id);
+ }
+
+ evalWithDefault(expr) {
+ return fakeTargetingContext.evalWithDefault(expr);
+ }
+ }
+ );
+ evalStub = fakeTargetingContext.evalWithDefault;
+ });
+ afterEach(() => {
+ clock.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+ it("should provide message.id as source", async () => {
+ await ASRouterTargeting.checkMessageTargeting(
+ {
+ id: "message",
+ targeting: "true",
+ },
+ fakeTargetingContext,
+ sandbox.stub(),
+ false
+ );
+ assert.calledOnce(fakeTargetingContext.evalWithDefault);
+ assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true");
+ assert.calledWithExactly(
+ fakeTargetingContext.setTelemetrySource,
+ "message"
+ );
+ });
+ it("should cache evaluation result", async () => {
+ evalStub.resolves(true);
+ let targetingContext = new global.TargetingContext();
+
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl1" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl2" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl1" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+
+ assert.calledTwice(evalStub);
+ });
+ it("should not cache evaluation result", async () => {
+ evalStub.resolves(true);
+ let targetingContext = new global.TargetingContext();
+
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ false
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ false
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ false
+ );
+
+ assert.calledThrice(evalStub);
+ });
+ it("should expire cache entries", async () => {
+ evalStub.resolves(true);
+ let targetingContext = new global.TargetingContext();
+
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ clock.tick(5 * 60 * 1000 + 1);
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+
+ assert.calledTwice(evalStub);
+ });
+
+ describe("#findMatchingMessage", () => {
+ let matchStub;
+ let messages = [
+ { id: "FOO", targeting: "match" },
+ { id: "BAR", targeting: "match" },
+ { id: "BAZ" },
+ ];
+ beforeEach(() => {
+ matchStub = sandbox
+ .stub(ASRouterTargeting, "_isMessageMatch")
+ .callsFake(message => message.targeting === "match");
+ });
+ it("should return an array of matches if returnAll is true", async () => {
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ returnAll: true,
+ }),
+ [
+ { id: "FOO", targeting: "match" },
+ { id: "BAR", targeting: "match" },
+ ]
+ );
+ });
+ it("should return an empty array if no matches were found and returnAll is true", async () => {
+ matchStub.returns(false);
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ returnAll: true,
+ }),
+ []
+ );
+ });
+ it("should return the first match if returnAll is false", async () => {
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ }),
+ messages[0]
+ );
+ });
+ it("should return null if if no matches were found and returnAll is false", async () => {
+ matchStub.returns(false);
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ }),
+ null
+ );
+ });
+ });
+});
+
+/**
+ * Messages should be sorted in the following order:
+ * 1. Rank
+ * 2. Priority
+ * 3. If the message has targeting
+ * 4. Order or randomization, depending on input
+ */
+describe("getSortedMessages", () => {
+ let globals = new GlobalOverrider();
+ let sandbox;
+ beforeEach(() => {
+ globals.set({ ASRouterPreferences });
+ sandbox = sinon.createSandbox();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ /**
+ * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages,
+ * returns the items in the expected order.
+ *
+ * @param {Message[]} expectedOrderArray - The array of messages in its expected order
+ * @param {{}} options - The options param for getSortedMessages
+ * @returns
+ */
+ function assertSortsCorrectly(expectedOrderArray, options) {
+ const input = [...expectedOrderArray].reverse();
+ const result = getSortedMessages(input, options);
+ const indexes = result.map(message => expectedOrderArray.indexOf(message));
+ return assert.equal(
+ indexes.join(","),
+ [...expectedOrderArray.keys()].join(","),
+ "Messsages are out of order"
+ );
+ }
+
+ it("should sort messages by priority, then by targeting", () => {
+ assertSortsCorrectly([
+ { priority: 100, targeting: "isFoo" },
+ { priority: 100 },
+ { priority: 99 },
+ { priority: 1, targeting: "isFoo" },
+ { priority: 1 },
+ {},
+ ]);
+ });
+ it("should sort messages by priority, then targeting, then order if ordered param is true", () => {
+ assertSortsCorrectly(
+ [
+ { priority: 100, order: 4 },
+ { priority: 100, order: 5 },
+ { priority: 1, order: 3, targeting: "isFoo" },
+ { priority: 1, order: 0 },
+ { priority: 1, order: 1 },
+ { priority: 1, order: 2 },
+ { order: 0 },
+ ],
+ { ordered: true }
+ );
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js
new file mode 100644
index 0000000000..aa455e23a2
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js
@@ -0,0 +1,833 @@
+import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs";
+import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("ASRouterTriggerListeners", () => {
+ let sandbox;
+ let globals;
+ let existingWindow;
+ let isWindowPrivateStub;
+ const triggerHandler = () => {};
+ const openURLListener = ASRouterTriggerListeners.get("openURL");
+ const frequentVisitsListener = ASRouterTriggerListeners.get("frequentVisits");
+ const captivePortalLoginListener =
+ ASRouterTriggerListeners.get("captivePortalLogin");
+ const bookmarkedURLListener =
+ ASRouterTriggerListeners.get("openBookmarkedURL");
+ const openArticleURLListener = ASRouterTriggerListeners.get("openArticleURL");
+ const nthTabClosedListener = ASRouterTriggerListeners.get("nthTabClosed");
+ const idleListener = ASRouterTriggerListeners.get("activityAfterIdle");
+ const formAutofillListener = ASRouterTriggerListeners.get("formAutofill");
+ const cookieBannerDetectedListener = ASRouterTriggerListeners.get(
+ "cookieBannerDetected"
+ );
+ const cookieBannerHandledListener = ASRouterTriggerListeners.get(
+ "cookieBannerHandled"
+ );
+ const hosts = ["www.mozilla.com", "www.mozilla.org"];
+
+ const regionFake = {
+ _home: "cn",
+ _current: "cn",
+ get home() {
+ return this._home;
+ },
+ get current() {
+ return this._current;
+ },
+ };
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ existingWindow = {
+ gBrowser: {
+ addTabsProgressListener: sandbox.stub(),
+ removeTabsProgressListener: sandbox.stub(),
+ currentURI: { host: "" },
+ },
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ };
+ sandbox.spy(openURLListener, "init");
+ sandbox.spy(openURLListener, "uninit");
+ isWindowPrivateStub = sandbox.stub();
+ // Assume no window is private so that we execute the action
+ isWindowPrivateStub.returns(false);
+ globals.set("PrivateBrowsingUtils", {
+ isWindowPrivate: isWindowPrivateStub,
+ });
+ const ewUninit = new Map();
+ globals.set("EveryWindow", {
+ registerCallback: (id, init, uninit) => {
+ init(existingWindow);
+ ewUninit.set(id, uninit);
+ },
+ unregisterCallback: id => {
+ ewUninit.get(id)(existingWindow);
+ },
+ });
+ globals.set("Region", regionFake);
+ globals.set("ASRouterPreferences", ASRouterPreferences);
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("openBookmarkedURL", () => {
+ let observerStub;
+ describe("#init", () => {
+ beforeEach(() => {
+ observerStub = sandbox.stub(global.Services.obs, "addObserver");
+ sandbox
+ .stub(global.Services.wm, "getMostRecentBrowserWindow")
+ .returns({ gBrowser: { selectedBrowser: {} } });
+ });
+ afterEach(() => {
+ bookmarkedURLListener.uninit();
+ });
+ it("should set hosts to the recentBookmarks", async () => {
+ await bookmarkedURLListener.init(sandbox.stub());
+
+ assert.calledOnce(observerStub);
+ assert.calledWithExactly(
+ observerStub,
+ bookmarkedURLListener,
+ "bookmark-icon-updated"
+ );
+ });
+ it("should provide id to triggerHandler", async () => {
+ const newTriggerHandler = sinon.stub();
+ const subject = {};
+ await bookmarkedURLListener.init(newTriggerHandler);
+
+ bookmarkedURLListener.observe(
+ subject,
+ "bookmark-icon-updated",
+ "starred"
+ );
+
+ assert.calledOnce(newTriggerHandler);
+ assert.calledWithExactly(newTriggerHandler, subject, {
+ id: bookmarkedURLListener.id,
+ });
+ });
+ });
+ });
+
+ describe("captivePortal", () => {
+ describe("observe", () => {
+ it("should not call the trigger handler if _shouldShowCaptivePortalVPNPromo returns false", () => {
+ sandbox
+ .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo")
+ .returns(false);
+ captivePortalLoginListener._triggerHandler = sandbox.spy();
+
+ captivePortalLoginListener.observe(
+ null,
+ "captive-portal-login-success"
+ );
+
+ assert.notCalled(captivePortalLoginListener._triggerHandler);
+ });
+
+ it("should call the trigger handler if _shouldShowCaptivePortalVPNPromo returns true", () => {
+ sandbox
+ .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo")
+ .returns(true);
+ sandbox.stub(Services.wm, "getMostRecentBrowserWindow").returns({
+ gBrowser: {
+ selectedBrowser: true,
+ },
+ });
+ captivePortalLoginListener._triggerHandler = sandbox.spy();
+
+ captivePortalLoginListener.observe(
+ null,
+ "captive-portal-login-success"
+ );
+
+ assert.calledOnce(captivePortalLoginListener._triggerHandler);
+ });
+ });
+ });
+
+ describe("openArticleURL", () => {
+ describe("#init", () => {
+ beforeEach(() => {
+ globals.set(
+ "MatchPatternSet",
+ sandbox.stub().callsFake(patterns => ({
+ patterns,
+ matches: url => patterns.has(url),
+ }))
+ );
+ sandbox.stub(global.AboutReaderParent, "addMessageListener");
+ sandbox.stub(global.AboutReaderParent, "removeMessageListener");
+ });
+ afterEach(() => {
+ openArticleURLListener.uninit();
+ });
+ it("setup an event listener on init", () => {
+ openArticleURLListener.init(sandbox.stub(), hosts, hosts);
+
+ assert.calledOnce(global.AboutReaderParent.addMessageListener);
+ assert.calledWithExactly(
+ global.AboutReaderParent.addMessageListener,
+ openArticleURLListener.readerModeEvent,
+ sinon.match.object
+ );
+ });
+ it("should call triggerHandler correctly for matches [host match]", () => {
+ const stub = sandbox.stub();
+ const target = { currentURI: { host: hosts[0], spec: hosts[1] } };
+ openArticleURLListener.init(stub, hosts, hosts);
+
+ const [, { receiveMessage }] =
+ global.AboutReaderParent.addMessageListener.firstCall.args;
+ receiveMessage({ data: { isArticle: true }, target });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, target, {
+ id: openArticleURLListener.id,
+ param: { host: hosts[0], url: hosts[1] },
+ });
+ });
+ it("should call triggerHandler correctly for matches [pattern match]", () => {
+ const stub = sandbox.stub();
+ const target = { currentURI: { host: null, spec: hosts[1] } };
+ openArticleURLListener.init(stub, hosts, hosts);
+
+ const [, { receiveMessage }] =
+ global.AboutReaderParent.addMessageListener.firstCall.args;
+ receiveMessage({ data: { isArticle: true }, target });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, target, {
+ id: openArticleURLListener.id,
+ param: { host: null, url: hosts[1] },
+ });
+ });
+ it("should remove the message listener", () => {
+ openArticleURLListener.init(sandbox.stub(), hosts, hosts);
+ openArticleURLListener.uninit();
+
+ assert.calledOnce(global.AboutReaderParent.removeMessageListener);
+ });
+ });
+ });
+
+ describe("frequentVisits", () => {
+ let _triggerHandler;
+ beforeEach(() => {
+ _triggerHandler = sandbox.stub();
+ sandbox.useFakeTimers();
+ frequentVisitsListener.init(_triggerHandler, hosts);
+ });
+ afterEach(() => {
+ sandbox.clock.restore();
+ frequentVisitsListener.uninit();
+ });
+ it("should be initialized", () => {
+ assert.isTrue(frequentVisitsListener._initialized);
+ });
+ it("should listen for TabSelect events", () => {
+ assert.calledOnce(existingWindow.addEventListener);
+ assert.calledWith(
+ existingWindow.addEventListener,
+ "TabSelect",
+ frequentVisitsListener.onTabSwitch
+ );
+ });
+ it("should call _triggerHandler if the visit is valid (is recoreded)", () => {
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+
+ assert.calledOnce(_triggerHandler);
+ });
+ it("should call _triggerHandler only once", () => {
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+
+ assert.calledOnce(_triggerHandler);
+ });
+ it("should call _triggerHandler again after 15 minutes", () => {
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+ sandbox.clock.tick(15 * 60 * 1000 + 1);
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+
+ assert.calledTwice(_triggerHandler);
+ });
+ it("should call triggerHandler on valid hosts", () => {
+ const stub = sandbox.stub(frequentVisitsListener, "triggerHandler");
+ existingWindow.gBrowser.currentURI.host = hosts[0]; // eslint-disable-line prefer-destructuring
+
+ frequentVisitsListener.onTabSwitch({
+ target: { ownerGlobal: existingWindow },
+ });
+
+ assert.calledOnce(stub);
+ });
+ it("should not call triggerHandler on invalid hosts", () => {
+ const stub = sandbox.stub(frequentVisitsListener, "triggerHandler");
+ existingWindow.gBrowser.currentURI.host = "foo.com";
+
+ frequentVisitsListener.onTabSwitch({
+ target: { ownerGlobal: existingWindow },
+ });
+
+ assert.notCalled(stub);
+ });
+ describe("MatchPattern", () => {
+ beforeEach(() => {
+ globals.set(
+ "MatchPatternSet",
+ sandbox.stub().callsFake(patterns => ({ patterns: patterns || [] }))
+ );
+ });
+ afterEach(() => {
+ frequentVisitsListener.uninit();
+ });
+ it("should create a matchPatternSet", () => {
+ frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]);
+
+ assert.calledOnce(window.MatchPatternSet);
+ assert.calledWithExactly(
+ window.MatchPatternSet,
+ new Set(["pattern"]),
+ undefined
+ );
+ });
+ it("should allow to add multiple patterns and dedupe", () => {
+ frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]);
+ frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]);
+
+ assert.calledTwice(window.MatchPatternSet);
+ assert.calledWithExactly(
+ window.MatchPatternSet,
+ new Set(["pattern", "foo"]),
+ undefined
+ );
+ });
+ it("should handle bad arguments to MatchPatternSet", () => {
+ const badArgs = ["www.example.com"];
+ window.MatchPatternSet.withArgs(new Set(badArgs)).throws();
+ frequentVisitsListener.init(_triggerHandler, hosts, badArgs);
+
+ // Fails with an empty MatchPatternSet
+ assert.property(frequentVisitsListener._matchPatternSet, "patterns");
+
+ // Second try is succesful
+ frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]);
+
+ assert.property(frequentVisitsListener._matchPatternSet, "patterns");
+ assert.isTrue(
+ frequentVisitsListener._matchPatternSet.patterns.has("foo")
+ );
+ });
+ });
+ });
+
+ describe("nthTabClosed", () => {
+ describe("#init", () => {
+ beforeEach(() => {
+ nthTabClosedListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ nthTabClosedListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(nthTabClosedListener._initialized);
+ assert.equal(nthTabClosedListener._triggerHandler, triggerHandler);
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ nthTabClosedListener.init(newTriggerHandler);
+ assert.ok(nthTabClosedListener._initialized);
+ assert.equal(nthTabClosedListener._triggerHandler, newTriggerHandler);
+ });
+
+ it("should add an event listeners to all existing browser windows", () => {
+ assert.calledOnce(existingWindow.addEventListener);
+ assert.calledWith(existingWindow.addEventListener, "TabClose");
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ nthTabClosedListener.init(triggerHandler);
+ nthTabClosedListener.uninit();
+ });
+ it("should set ._initialized to false and clear the triggerHandler, closed tabs count", () => {
+ assert.notOk(nthTabClosedListener._initialized);
+ assert.equal(nthTabClosedListener._triggerHandler, null);
+ assert.equal(nthTabClosedListener._closedTabs, 0);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ nthTabClosedListener.uninit();
+ assert.notOk(nthTabClosedListener._initialized);
+ });
+
+ it("should remove event listeners from all existing browser windows", () => {
+ assert.calledOnce(existingWindow.removeEventListener);
+ });
+ });
+ });
+
+ describe("activityAfterIdle", () => {
+ let addObsStub;
+ let removeObsStub;
+ describe("#init", () => {
+ beforeEach(() => {
+ addObsStub = sandbox.stub(global.Services.obs, "addObserver");
+ sandbox
+ .stub(global.Services.wm, "getEnumerator")
+ .returns([{ closed: false, document: { hidden: false } }]);
+ idleListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ idleListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(idleListener._initialized);
+ assert.equal(idleListener._triggerHandler, triggerHandler);
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ idleListener.init(newTriggerHandler);
+ assert.ok(idleListener._initialized);
+ assert.equal(idleListener._triggerHandler, newTriggerHandler);
+ });
+
+ it("should add observers for idle and activity", () => {
+ assert.called(addObsStub);
+ });
+
+ it("should add event listeners to all existing browser windows", () => {
+ assert.called(existingWindow.addEventListener);
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ removeObsStub = sandbox.stub(global.Services.obs, "removeObserver");
+ sandbox.stub(global.Services.wm, "getEnumerator").returns([]);
+ idleListener.init(triggerHandler);
+ idleListener.uninit();
+ });
+ it("should set ._initialized to false and clear the triggerHandler and timestamps", () => {
+ assert.notOk(idleListener._initialized);
+ assert.equal(idleListener._triggerHandler, null);
+ assert.equal(idleListener._quietSince, null);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ idleListener.uninit();
+ assert.notOk(idleListener._initialized);
+ });
+
+ it("should remove observers for idle and activity", () => {
+ assert.called(removeObsStub);
+ });
+
+ it("should remove event listeners from all existing browser windows", () => {
+ assert.called(existingWindow.removeEventListener);
+ });
+ });
+ });
+
+ describe("formAutofill", () => {
+ let addObsStub;
+ let removeObsStub;
+ describe("#init", () => {
+ beforeEach(() => {
+ addObsStub = sandbox.stub(global.Services.obs, "addObserver");
+ formAutofillListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ formAutofillListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(formAutofillListener._initialized);
+ assert.equal(formAutofillListener._triggerHandler, triggerHandler);
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ formAutofillListener.init(newTriggerHandler);
+ assert.ok(formAutofillListener._initialized);
+ assert.equal(formAutofillListener._triggerHandler, newTriggerHandler);
+ });
+
+ it(`should add observer for ${formAutofillListener._topic}`, () => {
+ assert.called(addObsStub);
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ removeObsStub = sandbox.stub(global.Services.obs, "removeObserver");
+ formAutofillListener.init(triggerHandler);
+ formAutofillListener.uninit();
+ });
+
+ it("should set ._initialized to false and clear the triggerHandler", () => {
+ assert.notOk(formAutofillListener._initialized);
+ assert.equal(formAutofillListener._triggerHandler, null);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ formAutofillListener.uninit();
+ assert.notOk(formAutofillListener._initialized);
+ });
+
+ it(`should remove observers for ${formAutofillListener._topic}`, () => {
+ assert.called(removeObsStub);
+ });
+ });
+ });
+
+ describe("openURL listener", () => {
+ it("should exist and initially be uninitialised", () => {
+ assert.ok(openURLListener);
+ assert.notOk(openURLListener._initialized);
+ });
+
+ describe("#init", () => {
+ beforeEach(() => {
+ openURLListener.init(triggerHandler, hosts);
+ });
+ afterEach(() => {
+ openURLListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler and hosts", () => {
+ assert.ok(openURLListener._initialized);
+ assert.deepEqual(openURLListener._hosts, new Set(hosts));
+ assert.equal(openURLListener._triggerHandler, triggerHandler);
+ });
+
+ it("should add tab progress listeners to all existing browser windows", () => {
+ assert.calledOnce(existingWindow.gBrowser.addTabsProgressListener);
+ assert.calledWithExactly(
+ existingWindow.gBrowser.addTabsProgressListener,
+ openURLListener
+ );
+ });
+
+ it("if already initialised, should only update the trigger handler and add the new hosts", () => {
+ const newHosts = ["www.example.com"];
+ const newTriggerHandler = () => {};
+ existingWindow.gBrowser.addTabsProgressListener.reset();
+
+ openURLListener.init(newTriggerHandler, newHosts);
+ assert.ok(openURLListener._initialized);
+ assert.deepEqual(
+ openURLListener._hosts,
+ new Set([...hosts, ...newHosts])
+ );
+ assert.equal(openURLListener._triggerHandler, newTriggerHandler);
+ assert.notCalled(existingWindow.gBrowser.addTabsProgressListener);
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ openURLListener.init(triggerHandler, hosts);
+ openURLListener.uninit();
+ });
+
+ it("should set ._initialized to false and clear the triggerHandler and hosts", () => {
+ assert.notOk(openURLListener._initialized);
+ assert.equal(openURLListener._hosts, null);
+ assert.equal(openURLListener._triggerHandler, null);
+ });
+
+ it("should remove tab progress listeners from all existing browser windows", () => {
+ assert.calledOnce(existingWindow.gBrowser.removeTabsProgressListener);
+ assert.calledWithExactly(
+ existingWindow.gBrowser.removeTabsProgressListener,
+ openURLListener
+ );
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ existingWindow.gBrowser.removeTabsProgressListener.reset();
+
+ openURLListener.uninit();
+ assert.notOk(openURLListener._initialized);
+ assert.notCalled(existingWindow.gBrowser.removeTabsProgressListener);
+ });
+ });
+
+ describe("#onLocationChange", () => {
+ afterEach(() => {
+ openURLListener.uninit();
+ frequentVisitsListener.uninit();
+ });
+
+ it("should call the ._triggerHandler with the right arguments", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const location = "www.mozilla.org";
+ openURLListener.onLocationChange(browser, webProgress, undefined, {
+ host: location,
+ spec: location,
+ });
+ assert.calledOnce(newTriggerHandler);
+ assert.calledWithExactly(newTriggerHandler, browser, {
+ id: "openURL",
+ param: { host: "www.mozilla.org", url: "www.mozilla.org" },
+ context: { visitsCount: 1 },
+ });
+ });
+ it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => {
+ for (let trigger of [openURLListener, frequentVisitsListener]) {
+ const newTriggerHandler = sinon.stub();
+ trigger.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ trigger.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledOnce(aRequest.QueryInterface);
+ assert.calledOnce(newTriggerHandler);
+ }
+ });
+ it("should call triggerHandler with the right arguments (redirect)", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ openURLListener.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledWithExactly(newTriggerHandler, browser, {
+ id: "openURL",
+ param: { host: "www.mozilla.org", url: "www.mozilla.org" },
+ context: { visitsCount: 1 },
+ });
+ });
+ it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => {
+ for (let trigger of [openURLListener, frequentVisitsListener]) {
+ const newTriggerHandler = sinon.stub();
+ trigger.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ trigger.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledOnce(aRequest.QueryInterface);
+ assert.calledOnce(newTriggerHandler);
+ }
+ });
+ it("should call triggerHandler with the right arguments (redirect)", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ openURLListener.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledWithExactly(newTriggerHandler, browser, {
+ id: "openURL",
+ param: { host: "www.mozilla.org", url: "www.mozilla.org" },
+ context: { visitsCount: 1 },
+ });
+ });
+ it("should fail for subdomains (not redirect)", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: {
+ spec: "subdomain.mozilla.org",
+ host: "subdomain.mozilla.org",
+ },
+ }),
+ };
+ openURLListener.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledOnce(aRequest.QueryInterface);
+ assert.notCalled(newTriggerHandler);
+ });
+ });
+ });
+
+ describe("cookieBannerDetected", () => {
+ describe("#init", () => {
+ beforeEach(() => {
+ cookieBannerDetectedListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ cookieBannerDetectedListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(cookieBannerDetectedListener._initialized);
+ assert.equal(
+ cookieBannerDetectedListener._triggerHandler,
+ triggerHandler
+ );
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ cookieBannerDetectedListener.init(newTriggerHandler);
+ assert.ok(cookieBannerDetectedListener._initialized);
+ assert.equal(
+ cookieBannerDetectedListener._triggerHandler,
+ newTriggerHandler
+ );
+ });
+
+ it("should add an event listeners to all existing browser windows", () => {
+ assert.calledOnce(existingWindow.addEventListener);
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ cookieBannerDetectedListener.init(triggerHandler);
+ cookieBannerDetectedListener.uninit();
+ });
+ it("should set ._initialized to false and clear the triggerHandler and timestamps", () => {
+ assert.notOk(cookieBannerDetectedListener._initialized);
+ assert.equal(cookieBannerDetectedListener._triggerHandler, null);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ cookieBannerDetectedListener.uninit();
+ assert.notOk(cookieBannerDetectedListener._initialized);
+ });
+
+ it("should remove event listeners from all existing browser windows", () => {
+ assert.called(existingWindow.removeEventListener);
+ });
+ });
+ });
+
+ describe("cookieBannerHandled", () => {
+ describe("#init", () => {
+ beforeEach(() => {
+ cookieBannerHandledListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ cookieBannerHandledListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(cookieBannerHandledListener._initialized);
+ assert.equal(
+ cookieBannerHandledListener._triggerHandler,
+ triggerHandler
+ );
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ cookieBannerHandledListener.init(newTriggerHandler);
+ assert.ok(cookieBannerHandledListener._initialized);
+ assert.equal(
+ cookieBannerHandledListener._triggerHandler,
+ newTriggerHandler
+ );
+ });
+
+ it("should add an event listeners to all existing browser windows", () => {
+ assert.calledOnce(existingWindow.addEventListener);
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ cookieBannerHandledListener.init(triggerHandler);
+ cookieBannerHandledListener.uninit();
+ });
+ it("should set ._initialized to false and clear the triggerHandler and timestamps", () => {
+ assert.notOk(cookieBannerHandledListener._initialized);
+ assert.equal(cookieBannerHandledListener._triggerHandler, null);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ cookieBannerHandledListener.uninit();
+ assert.notOk(cookieBannerHandledListener._initialized);
+ });
+
+ it("should remove event listeners from all existing browser windows", () => {
+ assert.called(existingWindow.removeEventListener);
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js
new file mode 100644
index 0000000000..fe6959852c
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js
@@ -0,0 +1,32 @@
+import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs";
+
+const REGULAR_IDS = [
+ "FACEBOOK_CONTAINER",
+ "GOOGLE_TRANSLATE",
+ "YOUTUBE_ENHANCE",
+ // These are excluded for now.
+ // "WIKIPEDIA_CONTEXT_MENU_SEARCH",
+ // "REDDIT_ENHANCEMENT",
+];
+
+describe("CFRMessageProvider", () => {
+ let messages;
+ beforeEach(async () => {
+ messages = await CFRMessageProvider.getMessages();
+ });
+ it("should have a total of 11 messages", () => {
+ assert.lengthOf(messages, 11);
+ });
+ it("should have one message each for the three regular addons", () => {
+ for (const id of REGULAR_IDS) {
+ const cohort3 = messages.find(msg => msg.id === `${id}_3`);
+ assert.ok(cohort3, `contains three day cohort for ${id}`);
+ assert.deepEqual(
+ cohort3.frequency,
+ { lifetime: 3 },
+ "three day cohort has the right frequency cap"
+ );
+ assert.notInclude(cohort3.targeting, `providerCohorts.cfr`);
+ }
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/CFRPageActions.test.js b/browser/components/asrouter/tests/unit/CFRPageActions.test.js
new file mode 100644
index 0000000000..31970eb43a
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/CFRPageActions.test.js
@@ -0,0 +1,1414 @@
+/* eslint max-nested-callbacks: ["error", 100] */
+
+import { CFRPageActions, PageAction } from "modules/CFRPageActions.sys.mjs";
+import { FAKE_RECOMMENDATION } from "./constants";
+import { GlobalOverrider } from "test/unit/utils";
+import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs";
+
+describe("CFRPageActions", () => {
+ let sandbox;
+ let clock;
+ let fakeRecommendation;
+ let fakeHost;
+ let fakeBrowser;
+ let dispatchStub;
+ let globals;
+ let containerElem;
+ let elements;
+ let announceStub;
+ let fakeRemoteL10n;
+ let isElmVisibleStub;
+ let getWidgetStub;
+
+ const elementIDs = [
+ "urlbar",
+ "urlbar-input",
+ "contextual-feature-recommendation",
+ "cfr-button",
+ "cfr-label",
+ "contextual-feature-recommendation-notification",
+ "cfr-notification-header-label",
+ "cfr-notification-header-link",
+ "cfr-notification-header-image",
+ "cfr-notification-author",
+ "cfr-notification-footer",
+ "cfr-notification-footer-text",
+ "cfr-notification-footer-filled-stars",
+ "cfr-notification-footer-empty-stars",
+ "cfr-notification-footer-users",
+ "cfr-notification-footer-spacer",
+ "cfr-notification-footer-learn-more-link",
+ ];
+ const elementClassNames = ["popup-notification-body-container"];
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ clock = sandbox.useFakeTimers();
+ isElmVisibleStub = sandbox.stub().returns(true);
+ getWidgetStub = sandbox.stub();
+
+ announceStub = sandbox.stub();
+ const A11yUtils = { announce: announceStub };
+ fakeRecommendation = { ...FAKE_RECOMMENDATION };
+ fakeHost = "mozilla.org";
+ fakeBrowser = {
+ documentURI: {
+ scheme: "https",
+ host: fakeHost,
+ },
+ ownerGlobal: window,
+ };
+ dispatchStub = sandbox.stub();
+
+ fakeRemoteL10n = {
+ l10n: {},
+ reloadL10n: sandbox.stub(),
+ createElement: sandbox.stub().returns(document.createElement("div")),
+ };
+
+ const gURLBar = document.createElement("div");
+ gURLBar.textbox = document.createElement("div");
+
+ globals = new GlobalOverrider();
+ globals.set({
+ RemoteL10n: fakeRemoteL10n,
+ promiseDocumentFlushed: sandbox
+ .stub()
+ .callsFake(fn => Promise.resolve(fn())),
+ PopupNotifications: {
+ show: sandbox.stub(),
+ remove: sandbox.stub(),
+ },
+ PrivateBrowsingUtils: { isWindowPrivate: sandbox.stub().returns(false) },
+ gBrowser: { selectedBrowser: fakeBrowser },
+ A11yUtils,
+ gURLBar,
+ isElementVisible: isElmVisibleStub,
+ CustomizableUI: { getWidget: getWidgetStub },
+ });
+ document.createXULElement = document.createElement;
+
+ elements = {};
+ const [body] = document.getElementsByTagName("body");
+ containerElem = document.createElement("div");
+ body.appendChild(containerElem);
+ for (const id of elementIDs) {
+ const elem = document.createElement("div");
+ elem.setAttribute("id", id);
+ containerElem.appendChild(elem);
+ elements[id] = elem;
+ }
+ for (const className of elementClassNames) {
+ const elem = document.createElement("div");
+ elem.setAttribute("class", className);
+ containerElem.appendChild(elem);
+ elements[className] = elem;
+ }
+ });
+
+ afterEach(() => {
+ CFRPageActions.clearRecommendations();
+ containerElem.remove();
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("PageAction", () => {
+ let pageAction;
+
+ beforeEach(() => {
+ pageAction = new PageAction(window, dispatchStub);
+ });
+
+ describe("#addImpression", () => {
+ it("should call _sendTelemetry with the impression payload", () => {
+ const recommendation = {
+ id: "foo",
+ content: { bucket_id: "bar" },
+ };
+ sandbox.spy(pageAction, "_sendTelemetry");
+
+ pageAction.addImpression(recommendation);
+
+ assert.calledWith(pageAction._sendTelemetry, {
+ message_id: "foo",
+ bucket_id: "bar",
+ event: "IMPRESSION",
+ });
+ });
+ });
+
+ describe("#showAddressBarNotifier", () => {
+ it("should un-hideAddressBarNotifier the element and set the right label value", async () => {
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ assert.isFalse(pageAction.container.hidden);
+ assert.equal(
+ pageAction.label.value,
+ fakeRecommendation.content.notification_text
+ );
+ });
+ it("should wait for the document layout to flush", async () => {
+ sandbox.spy(pageAction.label, "getClientRects");
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ assert.calledOnce(global.promiseDocumentFlushed);
+ assert.callOrder(
+ global.promiseDocumentFlushed,
+ pageAction.label.getClientRects
+ );
+ });
+ it("should set the CSS variable --cfr-label-width correctly", async () => {
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ const expectedWidth = pageAction.label.getClientRects()[0].width;
+ assert.equal(
+ pageAction.urlbarinput.style.getPropertyValue("--cfr-label-width"),
+ `${expectedWidth}px`
+ );
+ });
+ it("should cause an expansion, and dispatch an impression if `expand` is true", async () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ sandbox.spy(pageAction, "_expand");
+ sandbox.spy(pageAction, "_dispatchImpression");
+
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ assert.notCalled(pageAction._dispatchImpression);
+ clock.tick(1001);
+ assert.notEqual(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+
+ await pageAction.showAddressBarNotifier(fakeRecommendation, true);
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ clock.tick(1001);
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+ assert.calledOnce(pageAction._dispatchImpression);
+ assert.calledWith(pageAction._dispatchImpression, fakeRecommendation);
+ });
+ it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => {
+ await pageAction.showAddressBarNotifier(fakeRecommendation, true);
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "IMPRESSION",
+ },
+ });
+ });
+ });
+
+ describe("#hideAddressBarNotifier", () => {
+ it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ pageAction.hideAddressBarNotifier();
+ assert.isTrue(pageAction.container.hidden);
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ assert.isNull(
+ pageAction.urlbar.getAttribute("cfr-recommendation-state")
+ );
+ });
+ it("should remove the `currentNotification`", () => {
+ const notification = {};
+ pageAction.currentNotification = notification;
+ pageAction.hideAddressBarNotifier();
+ assert.calledWith(global.PopupNotifications.remove, notification);
+ });
+ });
+
+ describe("#_expand", () => {
+ beforeEach(() => {
+ pageAction._clearScheduledStateChanges();
+ pageAction.urlbar.removeAttribute("cfr-recommendation-state");
+ });
+ it("without a delay, should clear other state changes and set the state to 'expanded'", () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ pageAction._expand();
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+ });
+ it("with a delay, should set the expanded state after the correct amount of time", () => {
+ const delay = 1234;
+ pageAction._expand(delay);
+ // We expect that an expansion has been scheduled
+ assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
+ clock.tick(delay + 1);
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+ });
+ });
+
+ describe("#_collapse", () => {
+ beforeEach(() => {
+ pageAction._clearScheduledStateChanges();
+ pageAction.urlbar.removeAttribute("cfr-recommendation-state");
+ });
+ it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ pageAction._collapse();
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ assert.isNull(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state")
+ );
+ pageAction.urlbarinput.setAttribute(
+ "cfr-recommendation-state",
+ "expanded"
+ );
+ pageAction._collapse();
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "collapsed"
+ );
+ });
+ it("with a delay, should set the collapsed state after the correct amount of time", () => {
+ const delay = 1234;
+ pageAction._collapse(delay);
+ clock.tick(delay + 1);
+ // The state was _not_ "expanded" and so should not have been set to "collapsed"
+ assert.isNull(
+ pageAction.urlbar.getAttribute("cfr-recommendation-state")
+ );
+
+ pageAction._expand();
+ pageAction._collapse(delay);
+ // We expect that a collapse has been scheduled
+ assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
+ clock.tick(delay + 1);
+ // This time it was "expanded" so should now (after the delay) be "collapsed"
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "collapsed"
+ );
+ });
+ });
+
+ describe("#_clearScheduledStateChanges", () => {
+ it("should call .clearTimeout on all stored timeoutIDs", () => {
+ pageAction.stateTransitionTimeoutIDs = [42, 73, 1997];
+ sandbox.spy(pageAction.window, "clearTimeout");
+ pageAction._clearScheduledStateChanges();
+ assert.calledThrice(pageAction.window.clearTimeout);
+ assert.calledWith(pageAction.window.clearTimeout, 42);
+ assert.calledWith(pageAction.window.clearTimeout, 73);
+ assert.calledWith(pageAction.window.clearTimeout, 1997);
+ });
+ });
+
+ describe("#_popupStateChange", () => {
+ it("should collapse the notification and send dismiss telemetry on 'dismissed'", () => {
+ pageAction._expand();
+
+ sandbox.spy(pageAction, "_sendTelemetry");
+
+ pageAction._popupStateChange("dismissed");
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "collapsed"
+ );
+
+ assert.equal(
+ pageAction._sendTelemetry.lastCall.args[0].event,
+ "DISMISS"
+ );
+ });
+ it("should remove the notification on 'removed'", () => {
+ pageAction._expand();
+ const fakeNotification = {};
+
+ pageAction.currentNotification = fakeNotification;
+ pageAction._popupStateChange("removed");
+ assert.calledOnce(global.PopupNotifications.remove);
+ assert.calledWith(global.PopupNotifications.remove, fakeNotification);
+ });
+ it("should do nothing for other states", () => {
+ pageAction._popupStateChange("opened");
+ assert.notCalled(global.PopupNotifications.remove);
+ });
+ });
+
+ describe("#dispatchUserAction", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ const fakeAction = {};
+ pageAction.dispatchUserAction(fakeAction);
+ assert.calledOnce(dispatchStub);
+ assert.calledWith(
+ dispatchStub,
+ { type: "USER_ACTION", data: fakeAction },
+ fakeBrowser
+ );
+ });
+ });
+
+ describe("#_dispatchImpression", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ pageAction._dispatchImpression("fake impression");
+ assert.calledWith(dispatchStub, {
+ type: "IMPRESSION",
+ data: "fake impression",
+ });
+ });
+ });
+
+ describe("#_sendTelemetry", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ const fakePing = { message_id: 42 };
+ pageAction._sendTelemetry(fakePing);
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: 42,
+ },
+ });
+ });
+ });
+
+ describe("#_blockMessage", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ pageAction._blockMessage("fake id");
+ assert.calledOnce(dispatchStub);
+ assert.calledWith(dispatchStub, {
+ type: "BLOCK_MESSAGE_BY_ID",
+ data: { id: "fake id" },
+ });
+ });
+ });
+
+ describe("#getStrings", () => {
+ let formatMessagesStub;
+ const localeStrings = [
+ {
+ value: "你好世界",
+ attributes: [
+ { name: "first_attr", value: 42 },
+ { name: "second_attr", value: "some string" },
+ { name: "third_attr", value: [1, 2, 3] },
+ ],
+ },
+ ];
+
+ beforeEach(() => {
+ formatMessagesStub = sandbox
+ .stub()
+ .withArgs({ id: "hello_world" })
+ .resolves(localeStrings);
+ global.RemoteL10n.l10n.formatMessages = formatMessagesStub;
+ });
+
+ it("should return the argument if a string_id is not defined", async () => {
+ assert.deepEqual(await pageAction.getStrings({}), {});
+ assert.equal(await pageAction.getStrings("some string"), "some string");
+ });
+ it("should get the right locale string", async () => {
+ assert.equal(
+ await pageAction.getStrings({ string_id: "hello_world" }),
+ localeStrings[0].value
+ );
+ });
+ it("should return the right sub-attribute if specified", async () => {
+ assert.equal(
+ await pageAction.getStrings(
+ { string_id: "hello_world" },
+ "second_attr"
+ ),
+ "some string"
+ );
+ });
+ it("should attach attributes to string overrides", async () => {
+ const fromJson = { value: "Add Now", attributes: { accesskey: "A" } };
+
+ const result = await pageAction.getStrings(fromJson);
+
+ assert.equal(result, fromJson.value);
+ assert.propertyVal(result.attributes, "accesskey", "A");
+ });
+ it("should return subAttributes when doing string overrides", async () => {
+ const fromJson = { value: "Add Now", attributes: { accesskey: "A" } };
+
+ const result = await pageAction.getStrings(fromJson, "accesskey");
+
+ assert.equal(result, "A");
+ });
+ it("should resolve ftl strings and attach subAttributes", async () => {
+ const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" };
+ formatMessagesStub.resolves([
+ { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] },
+ ]);
+
+ const result = await pageAction.getStrings(fromFtl);
+
+ assert.equal(result, "Add Now");
+ assert.propertyVal(result.attributes, "accesskey", "A");
+ });
+ it("should return subAttributes from ftl ids", async () => {
+ const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" };
+ formatMessagesStub.resolves([
+ { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] },
+ ]);
+
+ const result = await pageAction.getStrings(fromFtl, "accesskey");
+
+ assert.equal(result, "A");
+ });
+ it("should report an error when no attributes are present but subAttribute is requested", async () => {
+ const fromJson = { value: "Foo" };
+ const stub = sandbox.stub(global.console, "error");
+
+ await pageAction.getStrings(fromJson, "accesskey");
+
+ assert.calledOnce(stub);
+ stub.restore();
+ });
+ });
+
+ describe("#_cfrUrlbarButtonClick", () => {
+ let translateElementsStub;
+ let setAttributesStub;
+ let getStringsStub;
+ beforeEach(async () => {
+ CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction);
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ getStringsStub = sandbox.stub(pageAction, "getStrings").resolves("");
+ getStringsStub
+ .callsFake(async a => a) // eslint-disable-line max-nested-callbacks
+ .withArgs({ string_id: "primary_button_id" })
+ .resolves({ value: "Primary Button", attributes: { accesskey: "p" } })
+ .withArgs({ string_id: "secondary_button_id" })
+ .resolves({
+ value: "Secondary Button",
+ attributes: { accesskey: "s" },
+ })
+ .withArgs({ string_id: "secondary_button_id_2" })
+ .resolves({
+ value: "Secondary Button 2",
+ attributes: { accesskey: "a" },
+ })
+ .withArgs({ string_id: "secondary_button_id_3" })
+ .resolves({
+ value: "Secondary Button 3",
+ attributes: { accesskey: "g" },
+ })
+ .withArgs(
+ sinon.match({
+ string_id: "cfr-doorhanger-extension-learn-more-link",
+ })
+ )
+ .resolves("Learn more")
+ .withArgs(
+ sinon.match({ string_id: "cfr-doorhanger-extension-total-users" })
+ )
+ .callsFake(async ({ args }) => `${args.total} users`); // eslint-disable-line max-nested-callbacks
+
+ translateElementsStub = sandbox.stub().resolves();
+ setAttributesStub = sandbox.stub();
+ global.RemoteL10n.l10n.setAttributes = setAttributesStub;
+ global.RemoteL10n.l10n.translateElements = translateElementsStub;
+ });
+
+ it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => {
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ CFRPageActions.RecommendationMap.delete(fakeBrowser);
+ await pageAction._cfrUrlbarButtonClick({});
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ assert.notCalled(global.PopupNotifications.show);
+ });
+ it("should cancel any planned state changes", async () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ assert.notCalled(pageAction._clearScheduledStateChanges);
+ await pageAction._cfrUrlbarButtonClick({});
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ });
+ it("should set the right text values", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+ const headerLabel = elements["cfr-notification-header-label"];
+ const headerLink = elements["cfr-notification-header-link"];
+ const headerImage = elements["cfr-notification-header-image"];
+ const footerLink = elements["cfr-notification-footer-learn-more-link"];
+ assert.equal(
+ headerLabel.value,
+ fakeRecommendation.content.heading_text
+ );
+ assert.isTrue(
+ headerLink
+ .getAttribute("href")
+ .endsWith(fakeRecommendation.content.info_icon.sumo_path)
+ );
+ assert.equal(
+ headerImage.getAttribute("tooltiptext"),
+ fakeRecommendation.content.info_icon.label
+ );
+ const htmlFooterEl = fakeRemoteL10n.createElement.args.find(
+ /* eslint-disable-next-line max-nested-callbacks */
+ ([doc, el, args]) =>
+ args && args.content === fakeRecommendation.content.text
+ );
+ assert.ok(htmlFooterEl);
+ assert.equal(footerLink.value, "Learn more");
+ assert.equal(
+ footerLink.getAttribute("href"),
+ fakeRecommendation.content.addon.amo_url
+ );
+ });
+ it("should add the rating correctly", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ const footerFilledStars =
+ elements["cfr-notification-footer-filled-stars"];
+ const footerEmptyStars =
+ elements["cfr-notification-footer-empty-stars"];
+ // .toFixed to sort out some floating precision errors
+ assert.equal(
+ footerFilledStars.style.width,
+ `${(4.2 * 16).toFixed(1)}px`
+ );
+ assert.equal(
+ footerEmptyStars.style.width,
+ `${(0.8 * 16).toFixed(1)}px`
+ );
+ });
+ it("should add the number of users correctly", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ const footerUsers = elements["cfr-notification-footer-users"];
+ assert.isNull(footerUsers.getAttribute("hidden"));
+ assert.equal(
+ footerUsers.getAttribute("value"),
+ `${fakeRecommendation.content.addon.users}`
+ );
+ });
+ it("should send the right telemetry", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "CLICK_DOORHANGER",
+ },
+ });
+ });
+ it("should set the main action correctly", async () => {
+ sinon
+ .stub(CFRPageActions, "_fetchLatestAddonVersion")
+ .resolves("latest-addon.xpi");
+ await pageAction._cfrUrlbarButtonClick();
+ const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring
+ assert.deepEqual(mainAction.label, {
+ value: "Primary Button",
+ attributes: { accesskey: "p" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ await mainAction.callback();
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ // Should block the message
+ assert.calledWith(dispatchStub, {
+ type: "BLOCK_MESSAGE_BY_ID",
+ data: { id: fakeRecommendation.id },
+ });
+ // Should trigger the action
+ assert.calledWith(
+ dispatchStub,
+ {
+ type: "USER_ACTION",
+ data: { id: "primary_action", data: { url: "latest-addon.xpi" } },
+ },
+ fakeBrowser
+ );
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "INSTALL",
+ },
+ });
+ // Should remove the recommendation
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should set the secondary action correctly", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ // eslint-disable-next-line prefer-destructuring
+ const [secondaryAction] =
+ global.PopupNotifications.show.firstCall.args[5];
+
+ assert.deepEqual(secondaryAction.label, {
+ value: "Secondary Button",
+ attributes: { accesskey: "s" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ CFRPageActions.RecommendationMap.set(fakeBrowser, {});
+ secondaryAction.callback();
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "DISMISS",
+ },
+ });
+ // Don't remove the recommendation on `DISMISS` action
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ assert.notCalled(pageAction.hideAddressBarNotifier);
+ });
+ it("should send right telemetry for BLOCK secondary action", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ // eslint-disable-next-line prefer-destructuring
+ const blockAction = global.PopupNotifications.show.firstCall.args[5][1];
+
+ assert.deepEqual(blockAction.label, {
+ value: "Secondary Button 2",
+ attributes: { accesskey: "a" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ sandbox.spy(pageAction, "_blockMessage");
+ CFRPageActions.RecommendationMap.set(fakeBrowser, {});
+ blockAction.callback();
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ assert.calledOnce(pageAction._blockMessage);
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "BLOCK",
+ },
+ });
+ // Should remove the recommendation
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should send right telemetry for MANAGE secondary action", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ // eslint-disable-next-line prefer-destructuring
+ const manageAction =
+ global.PopupNotifications.show.firstCall.args[5][2];
+
+ assert.deepEqual(manageAction.label, {
+ value: "Secondary Button 3",
+ attributes: { accesskey: "g" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ CFRPageActions.RecommendationMap.set(fakeBrowser, {});
+ manageAction.callback();
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "MANAGE",
+ },
+ });
+ // Don't remove the recommendation on `MANAGE` action
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ assert.notCalled(pageAction.hideAddressBarNotifier);
+ });
+ it("should call PopupNotifications.show with the right arguments", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ assert.calledWith(
+ global.PopupNotifications.show,
+ fakeBrowser,
+ "contextual-feature-recommendation",
+ fakeRecommendation.content.addon.title,
+ "cfr",
+ sinon.match.any, // Corresponds to the main action, tested above
+ sinon.match.any, // Corresponds to the secondary action, tested above
+ {
+ popupIconURL: fakeRecommendation.content.addon.icon,
+ hideClose: true,
+ eventCallback: pageAction._popupStateChange,
+ persistent: false,
+ persistWhileVisible: false,
+ popupIconClass: fakeRecommendation.content.icon_class,
+ recordTelemetryInPrivateBrowsing:
+ fakeRecommendation.content.show_in_private_browsing,
+ name: {
+ string_id: "cfr-doorhanger-extension-author",
+ args: { name: fakeRecommendation.content.addon.author },
+ },
+ }
+ );
+ });
+ });
+ describe("#_cfrUrlbarButtonClick/cfr_urlbar_chiclet", () => {
+ let heartbeatRecommendation;
+ beforeEach(async () => {
+ heartbeatRecommendation = (await CFRMessageProvider.getMessages()).find(
+ m => m.template === "cfr_urlbar_chiclet"
+ );
+ CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction);
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ heartbeatRecommendation,
+ dispatchStub
+ );
+ });
+ it("should dispatch a click event", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: heartbeatRecommendation.id,
+ bucket_id: heartbeatRecommendation.content.bucket_id,
+ event: "CLICK_DOORHANGER",
+ },
+ });
+ });
+ it("should dispatch a USER_ACTION for chiclet_open_url layout", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledWith(dispatchStub, {
+ type: "USER_ACTION",
+ data: {
+ data: {
+ args: heartbeatRecommendation.content.action.url,
+ where: heartbeatRecommendation.content.action.where,
+ },
+ type: "OPEN_URL",
+ },
+ });
+ });
+ it("should block the message after the click", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledWith(dispatchStub, {
+ type: "BLOCK_MESSAGE_BY_ID",
+ data: { id: heartbeatRecommendation.id },
+ });
+ });
+ it("should remove the button and browser entry", async () => {
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ });
+
+ describe("#showMilestonePopup", () => {
+ let milestoneRecommendation;
+ let fakeTrackingDBService;
+ beforeEach(async () => {
+ fakeTrackingDBService = {
+ sumAllEvents: sandbox.stub(),
+ };
+ globals.set({ TrackingDBService: fakeTrackingDBService });
+ CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction);
+ sandbox
+ .stub(pageAction, "getStrings")
+ .callsFake(async a => a) // eslint-disable-line max-nested-callbacks
+ .resolves({ value: "element", attributes: { accesskey: "e" } });
+
+ milestoneRecommendation = (await CFRMessageProvider.getMessages()).find(
+ m => m.template === "milestone_message"
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("Set current date in header when earliest date undefined", async () => {
+ fakeTrackingDBService.getEarliestRecordedDate = sandbox.stub();
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ milestoneRecommendation,
+ dispatchStub
+ );
+ const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find(
+ /* eslint-disable-next-line max-nested-callbacks */
+ ([doc, el, args]) => args && args.content && args.attributes
+ );
+ assert.equal(
+ headerElementArgs.content.string_id,
+ milestoneRecommendation.content.heading_text.string_id
+ );
+ assert.equal(headerElementArgs.attributes.date, new Date().getTime());
+ assert.calledOnce(global.PopupNotifications.show);
+ });
+
+ it("Set date in header to earliest date timestamp by default", async () => {
+ let earliestDateTimeStamp = 1705601996435;
+ fakeTrackingDBService.getEarliestRecordedDate = sandbox
+ .stub()
+ .returns(earliestDateTimeStamp);
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ milestoneRecommendation,
+ dispatchStub
+ );
+ const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find(
+ /* eslint-disable-next-line max-nested-callbacks */
+ ([doc, el, args]) => args && args.content && args.attributes
+ );
+ assert.equal(
+ headerElementArgs.content.string_id,
+ milestoneRecommendation.content.heading_text.string_id
+ );
+ assert.equal(headerElementArgs.attributes.date, earliestDateTimeStamp);
+ assert.calledOnce(global.PopupNotifications.show);
+ });
+ });
+ });
+
+ describe("CFRPageActions", () => {
+ beforeEach(() => {
+ // Spy on the prototype methods to inspect calls for any PageAction instance
+ sandbox.spy(PageAction.prototype, "showAddressBarNotifier");
+ sandbox.spy(PageAction.prototype, "hideAddressBarNotifier");
+ });
+
+ describe("updatePageActions", () => {
+ let savedRec;
+
+ beforeEach(() => {
+ const win = fakeBrowser.ownerGlobal;
+ CFRPageActions.PageActionMap.set(
+ win,
+ new PageAction(win, dispatchStub)
+ );
+ const { id, content } = fakeRecommendation;
+ savedRec = {
+ id,
+ host: fakeHost,
+ content,
+ };
+ CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec);
+ });
+
+ it("should do nothing if a pageAction doesn't exist for the window", () => {
+ const win = fakeBrowser.ownerGlobal;
+ CFRPageActions.PageActionMap.delete(win);
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.notCalled(PageAction.prototype.showAddressBarNotifier);
+ assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
+ });
+ it("should do nothing if the browser is not the `selectedBrowser`", () => {
+ const someOtherFakeBrowser = {};
+ CFRPageActions.updatePageActions(someOtherFakeBrowser);
+ assert.notCalled(PageAction.prototype.showAddressBarNotifier);
+ assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
+ });
+ it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => {
+ CFRPageActions.RecommendationMap.delete(fakeBrowser);
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ });
+ it("should show the pageAction if a recommendation exists and the host matches", () => {
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ assert.calledWith(
+ PageAction.prototype.showAddressBarNotifier,
+ savedRec
+ );
+ });
+ it("should show the pageAction if a recommendation exists and it doesn't have a host defined", () => {
+ const recNoHost = { ...savedRec, host: undefined };
+ CFRPageActions.RecommendationMap.set(fakeBrowser, recNoHost);
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ assert.calledWith(
+ PageAction.prototype.showAddressBarNotifier,
+ recNoHost
+ );
+ });
+ it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => {
+ const someOtherFakeHost = "subdomain.mozilla.com";
+ fakeBrowser.documentURI.host = someOtherFakeHost;
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should not call `delete` if retain is true", () => {
+ savedRec.retain = true;
+ fakeBrowser.documentURI.host = "subdomain.mozilla.com";
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.propertyVal(savedRec, "retain", false);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should call `delete` if retain is false", () => {
+ savedRec.retain = false;
+ fakeBrowser.documentURI.host = "subdomain.mozilla.com";
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.propertyVal(savedRec, "retain", false);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ });
+
+ describe("forceRecommendation", () => {
+ it("should succeed and add an element to the RecommendationMap", async () => {
+ assert.isTrue(
+ await CFRPageActions.forceRecommendation(
+ fakeBrowser,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ content: fakeRecommendation.content,
+ });
+ });
+ it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
+ const win = fakeBrowser.ownerGlobal;
+ assert.isFalse(CFRPageActions.PageActionMap.has(win));
+ await CFRPageActions.forceRecommendation(
+ fakeBrowser,
+ fakeRecommendation,
+ dispatchStub
+ );
+ const pageAction = CFRPageActions.PageActionMap.get(win);
+ assert.equal(win, pageAction.window);
+ assert.equal(dispatchStub, pageAction._dispatchCFRAction);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ });
+ });
+
+ describe("showPopup", () => {
+ let savedRec;
+ let pageAction;
+ let fakeAnchorId = "fake_anchor_id";
+ let fakeAltAnchorId = "fake_alt_anchor_id";
+ let TEST_MESSAGE;
+ let getElmStub;
+ let getStyleStub;
+ let isCustomizingStub;
+ beforeEach(() => {
+ TEST_MESSAGE = {
+ id: "fake_id",
+ template: "cfr_doorhanger",
+ content: {
+ skip_address_bar_notifier: true,
+ heading_text: "Fake Heading Text",
+ anchor_id: fakeAnchorId,
+ },
+ };
+ getElmStub = sandbox
+ .stub(window.document, "getElementById")
+ .callsFake(id => ({ id }));
+ getStyleStub = sandbox
+ .stub(window, "getComputedStyle")
+ .returns({ display: "block", visibility: "visible" });
+
+ isCustomizingStub = sandbox.stub().returns(false);
+ globals.set({
+ CustomizationHandler: { isCustomizing: isCustomizingStub },
+ });
+
+ savedRec = {
+ id: TEST_MESSAGE.id,
+ host: fakeHost,
+ content: TEST_MESSAGE.content,
+ };
+ CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec);
+ pageAction = new PageAction(window, dispatchStub);
+ sandbox.stub(pageAction, "_renderPopup");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should use anchor_id if element exists and is not a customizable widget", async () => {
+ await pageAction.showPopup();
+ assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId);
+ });
+
+ it("should use anchor_id if element exists and is in the toolbar", async () => {
+ getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: "toolbar" });
+ await pageAction.showPopup();
+ assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId);
+ });
+
+ it("should use the cfr button if element exists but is in the widget overflow panel", async () => {
+ getWidgetStub
+ .withArgs(fakeAnchorId)
+ .returns({ areaType: "menu-panel" });
+ await pageAction.showPopup();
+ assert.equal(
+ fakeBrowser.cfrpopupnotificationanchor.id,
+ pageAction.button.id
+ );
+ });
+
+ it("should use the cfr button if element exists but is in the customization palette", async () => {
+ getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: null });
+ isCustomizingStub.returns(true);
+ await pageAction.showPopup();
+ assert.equal(
+ fakeBrowser.cfrpopupnotificationanchor.id,
+ pageAction.button.id
+ );
+ });
+
+ it("should use alt_anchor_id if one has been provided and the anchor_id element cannot be found", async () => {
+ TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId;
+ getElmStub.withArgs(fakeAnchorId).returns(null);
+ await pageAction.showPopup();
+ assert.equal(
+ fakeBrowser.cfrpopupnotificationanchor.id,
+ fakeAltAnchorId
+ );
+ });
+
+ it("should use alt_anchor_id if one has been provided and the anchor_id element is hidden by CSS", async () => {
+ TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId;
+ getStyleStub
+ .withArgs(sandbox.match({ id: fakeAnchorId }))
+ .returns({ display: "none", visibility: "visible" });
+ await pageAction.showPopup();
+ assert.equal(
+ fakeBrowser.cfrpopupnotificationanchor.id,
+ fakeAltAnchorId
+ );
+ });
+
+ it("should use alt_anchor_id if one has been provided and the anchor_id element has no height/width", async () => {
+ TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId;
+ isElmVisibleStub
+ .withArgs(sandbox.match({ id: fakeAnchorId }))
+ .returns(false);
+ await pageAction.showPopup();
+ assert.equal(
+ fakeBrowser.cfrpopupnotificationanchor.id,
+ fakeAltAnchorId
+ );
+ });
+
+ it("should use the button if the anchor_id and alt_anchor_id are both not visible", async () => {
+ TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId;
+ getStyleStub
+ .withArgs(sandbox.match({ id: fakeAnchorId }))
+ .returns({ display: "none", visibility: "visible" });
+ getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null });
+ isCustomizingStub.returns(true);
+ await pageAction.showPopup();
+ assert.equal(
+ fakeBrowser.cfrpopupnotificationanchor.id,
+ pageAction.button.id
+ );
+ });
+
+ it("should use the default container if the anchor_id, alt_anchor_id, and cfr button are not visible", async () => {
+ TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId;
+ getStyleStub
+ .withArgs(sandbox.match({ id: fakeAnchorId }))
+ .returns({ display: "none", visibility: "visible" });
+ getStyleStub
+ .withArgs(sandbox.match({ id: "cfr-button" }))
+ .returns({ display: "none", visibility: "visible" });
+ getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null });
+ isCustomizingStub.returns(true);
+ await pageAction.showPopup();
+ assert.equal(
+ fakeBrowser.cfrpopupnotificationanchor.id,
+ pageAction.container.id
+ );
+ });
+ });
+
+ describe("addRecommendation", () => {
+ it("should fail and not add a recommendation if the browser is part of a private window", async () => {
+ global.PrivateBrowsingUtils.isWindowPrivate.returns(true);
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should successfully add a private browsing recommendation and send correct telemetry", async () => {
+ global.PrivateBrowsingUtils.isWindowPrivate.returns(true);
+ fakeRecommendation.content.show_in_private_browsing = true;
+ assert.isTrue(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+
+ const pageAction = CFRPageActions.PageActionMap.get(
+ fakeBrowser.ownerGlobal
+ );
+ await pageAction.showAddressBarNotifier(fakeRecommendation, true);
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ is_private: true,
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "IMPRESSION",
+ },
+ });
+ });
+ it("should fail and not add a recommendation if the browser is not the selected browser", async () => {
+ global.gBrowser.selectedBrowser = {}; // Some other browser
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ });
+ it("should fail and not add a recommendation if the browser does not exist", async () => {
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ undefined,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should fail and not add a recommendation if the host doesn't match", async () => {
+ const someOtherFakeHost = "subdomain.mozilla.com";
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ someOtherFakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ });
+ it("should otherwise succeed and add an element to the RecommendationMap", async () => {
+ assert.isTrue(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ host: fakeHost,
+ content: fakeRecommendation.content,
+ });
+ });
+ it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
+ const win = fakeBrowser.ownerGlobal;
+ assert.isFalse(CFRPageActions.PageActionMap.has(win));
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ const pageAction = CFRPageActions.PageActionMap.get(win);
+ assert.equal(win, pageAction.window);
+ assert.equal(dispatchStub, pageAction._dispatchCFRAction);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ });
+ it("should add the right url if we fetched and addon install URL", async () => {
+ fakeRecommendation.template = "cfr_doorhanger";
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ const recommendation =
+ CFRPageActions.RecommendationMap.get(fakeBrowser);
+
+ // sanity check - just go through some of the rest of the attributes to make sure they were untouched
+ assert.equal(recommendation.id, fakeRecommendation.id);
+ assert.equal(
+ recommendation.content.heading_text,
+ fakeRecommendation.content.heading_text
+ );
+ assert.equal(
+ recommendation.content.addon,
+ fakeRecommendation.content.addon
+ );
+ assert.equal(
+ recommendation.content.text,
+ fakeRecommendation.content.text
+ );
+ assert.equal(
+ recommendation.content.buttons.secondary,
+ fakeRecommendation.content.buttons.secondary
+ );
+ assert.equal(
+ recommendation.content.buttons.primary.action.id,
+ fakeRecommendation.content.buttons.primary.action.id
+ );
+
+ delete fakeRecommendation.template;
+ });
+ it("should prevent a second message if one is currently displayed", async () => {
+ const secondMessage = { ...fakeRecommendation, id: "second_message" };
+ let messageAdded = await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+
+ assert.isTrue(messageAdded);
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ host: fakeHost,
+ content: fakeRecommendation.content,
+ });
+
+ messageAdded = await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ secondMessage,
+ dispatchStub
+ );
+ // Adding failed
+ assert.isFalse(messageAdded);
+ // First message is still there
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ host: fakeHost,
+ content: fakeRecommendation.content,
+ });
+ });
+ it("should send impressions just for the first message", async () => {
+ const secondMessage = { ...fakeRecommendation, id: "second_message" };
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ secondMessage,
+ dispatchStub
+ );
+
+ // Doorhanger telemetry + Impression for just 1 message
+ assert.calledTwice(dispatchStub);
+ const [firstArgs] = dispatchStub.firstCall.args;
+ const [secondArgs] = dispatchStub.secondCall.args;
+ assert.equal(firstArgs.data.id, secondArgs.data.message_id);
+ });
+ });
+
+ describe("clearRecommendations", () => {
+ const createFakePageAction = () => ({
+ hideAddressBarNotifier: sandbox.stub(),
+ });
+ const windows = [{}, {}, { closed: true }];
+ const browsers = [{}, {}, {}, {}];
+
+ beforeEach(() => {
+ CFRPageActions.PageActionMap.set(windows[0], createFakePageAction());
+ CFRPageActions.PageActionMap.set(windows[2], createFakePageAction());
+ for (const browser of browsers) {
+ CFRPageActions.RecommendationMap.set(browser, {});
+ }
+ globals.set({ Services: { wm: { getEnumerator: () => windows } } });
+ });
+
+ it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => {
+ const pageActions = windows.map(win =>
+ CFRPageActions.PageActionMap.get(win)
+ );
+ CFRPageActions.clearRecommendations();
+
+ // Only the first window had a PageAction and wasn't closed
+ assert.calledOnce(pageActions[0].hideAddressBarNotifier);
+ assert.isUndefined(pageActions[1]);
+ assert.notCalled(pageActions[2].hideAddressBarNotifier);
+ });
+ it("should clear the PageActionMap and the RecommendationMap", () => {
+ CFRPageActions.clearRecommendations();
+
+ // Both are WeakMaps and so are not iterable, cannot be cleared, and
+ // cannot have their length queried directly, so we have to check
+ // whether previous elements still exist
+ assert.lengthOf(windows, 3);
+ for (const win of windows) {
+ assert.isFalse(CFRPageActions.PageActionMap.has(win));
+ }
+ assert.lengthOf(browsers, 4);
+ for (const browser of browsers) {
+ assert.isFalse(CFRPageActions.RecommendationMap.has(browser));
+ }
+ });
+ });
+
+ describe("reloadL10n", () => {
+ const createFakePageAction = () => ({
+ hideAddressBarNotifier() {},
+ reloadL10n: sandbox.stub(),
+ });
+ const windows = [{}, {}, { closed: true }];
+
+ beforeEach(() => {
+ CFRPageActions.PageActionMap.set(windows[0], createFakePageAction());
+ CFRPageActions.PageActionMap.set(windows[2], createFakePageAction());
+ globals.set({ Services: { wm: { getEnumerator: () => windows } } });
+ });
+
+ it("should call reloadL10n for all the PageActions of any existing, non-closed windows", () => {
+ const pageActions = windows.map(win =>
+ CFRPageActions.PageActionMap.get(win)
+ );
+ CFRPageActions.reloadL10n();
+
+ // Only the first window had a PageAction and wasn't closed
+ assert.calledOnce(pageActions[0].reloadL10n);
+ assert.isUndefined(pageActions[1]);
+ assert.notCalled(pageActions[2].reloadL10n);
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js
new file mode 100644
index 0000000000..463e388651
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js
@@ -0,0 +1,459 @@
+import { MessageLoaderUtils } from "modules/ASRouter.sys.mjs";
+const { STARTPAGE_VERSION } = MessageLoaderUtils;
+
+const FAKE_OPTIONS = {
+ storage: {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve();
+ },
+ },
+ dispatchToAS: () => {},
+};
+const FAKE_RESPONSE_HEADERS = { get() {} };
+
+describe("MessageLoaderUtils", () => {
+ let fetchStub;
+ let clock;
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ clock = sinon.useFakeTimers();
+ fetchStub = sinon.stub(global, "fetch");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ clock.restore();
+ fetchStub.restore();
+ });
+
+ describe("#loadMessagesForProvider", () => {
+ it("should return messages for a local provider with hardcoded messages", async () => {
+ const sourceMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "local",
+ messages: [sourceMessage],
+ };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.isArray(result.messages);
+ // Does the message have the right properties?
+ const [message] = result.messages;
+ assert.propertyVal(message, "id", "foo");
+ assert.propertyVal(message, "provider", "provider123");
+ });
+ it("should filter out local messages listed in the `exclude` field", async () => {
+ const sourceMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "local",
+ messages: [sourceMessage],
+ exclude: ["foo"],
+ };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.lengthOf(result.messages, 0);
+ });
+ it("should return messages for remote provider", async () => {
+ const sourceMessage = { id: "foo" };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ messages: [sourceMessage] }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "https://foo.com",
+ };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.isArray(result.messages);
+ // Does the message have the right properties?
+ const [message] = result.messages;
+ assert.propertyVal(message, "id", "foo");
+ assert.propertyVal(message, "provider", "provider123");
+ assert.propertyVal(message, "provider_url", "https://foo.com");
+ });
+ describe("remote provider HTTP codes", () => {
+ const testMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "https://foo.com",
+ updateCycleInMs: 300,
+ };
+ const respJson = { messages: [testMessage] };
+
+ function assertReturnsCorrectMessages(actual) {
+ assert.isArray(actual.messages);
+ // Does the message have the right properties?
+ const [message] = actual.messages;
+ assert.propertyVal(message, "id", testMessage.id);
+ assert.propertyVal(message, "provider", provider.id);
+ assert.propertyVal(message, "provider_url", provider.url);
+ }
+
+ it("should return messages for 200 response", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(respJson),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ assertReturnsCorrectMessages(
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ )
+ );
+ });
+
+ it("should return messages for a 302 response with json", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 302,
+ json: () => Promise.resolve(respJson),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ assertReturnsCorrectMessages(
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ )
+ );
+ });
+
+ it("should return an empty array for a 204 response", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 204,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+
+ it("should return an empty array for a 500 response", async () => {
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+
+ it("should return cached messages for a 304 response", async () => {
+ clock.tick(302);
+ const messages = [{ id: "message-1" }, { id: "message-2" }];
+ const fakeStorage = {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve({
+ [provider.id]: {
+ version: STARTPAGE_VERSION,
+ url: provider.url,
+ messages,
+ etag: "etag0987654321",
+ lastFetched: 1,
+ },
+ });
+ },
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 304,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ { ...FAKE_OPTIONS, storage: fakeStorage }
+ );
+ assert.equal(result.messages.length, messages.length);
+ messages.forEach(message => {
+ assert.ok(result.messages.find(m => m.id === message.id));
+ });
+ });
+
+ it("should return an empty array if json doesn't parse properly", async () => {
+ fetchStub.resolves({
+ ok: false,
+ status: 200,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+
+ it("should report response parsing errors with MessageLoaderUtils.reportError", async () => {
+ const err = {};
+ sandbox.spy(MessageLoaderUtils, "reportError");
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: sandbox.stub().rejects(err),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.calledOnce(MessageLoaderUtils.reportError);
+ // Report that json parsing failed
+ assert.calledWith(MessageLoaderUtils.reportError, err);
+ });
+
+ it("should report missing `messages` with MessageLoaderUtils.reportError", async () => {
+ sandbox.spy(MessageLoaderUtils, "reportError");
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: sandbox.stub().resolves({}),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.calledOnce(MessageLoaderUtils.reportError);
+ // Report no messages returned
+ assert.calledWith(
+ MessageLoaderUtils.reportError,
+ "No messages returned from https://foo.com."
+ );
+ });
+
+ it("should report bad status responses with MessageLoaderUtils.reportError", async () => {
+ sandbox.spy(MessageLoaderUtils, "reportError");
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ json: sandbox.stub().resolves({}),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.calledOnce(MessageLoaderUtils.reportError);
+ // Report no messages returned
+ assert.calledWith(
+ MessageLoaderUtils.reportError,
+ "Invalid response status 500 from https://foo.com."
+ );
+ });
+
+ it("should return an empty array if the request rejects", async () => {
+ fetchStub.rejects(new Error("something went wrong"));
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+ });
+ describe("remote provider caching", () => {
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "https://foo.com",
+ updateCycleInMs: 300,
+ };
+
+ it("should return cached results if they aren't expired", async () => {
+ clock.tick(1);
+ const messages = [{ id: "message-1" }, { id: "message-2" }];
+ const fakeStorage = {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve({
+ [provider.id]: {
+ version: STARTPAGE_VERSION,
+ url: provider.url,
+ messages,
+ etag: "etag0987654321",
+ lastFetched: Date.now(),
+ },
+ });
+ },
+ };
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ { ...FAKE_OPTIONS, storage: fakeStorage }
+ );
+ assert.equal(result.messages.length, messages.length);
+ messages.forEach(message => {
+ assert.ok(result.messages.find(m => m.id === message.id));
+ });
+ });
+
+ it("should return fetch results if the cache messages are expired", async () => {
+ clock.tick(302);
+ const testMessage = { id: "foo" };
+ const respJson = { messages: [testMessage] };
+ const fakeStorage = {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve({
+ [provider.id]: {
+ version: STARTPAGE_VERSION,
+ url: provider.url,
+ messages: [{ id: "message-1" }, { id: "message-2" }],
+ etag: "etag0987654321",
+ lastFetched: 1,
+ },
+ });
+ },
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(respJson),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ { ...FAKE_OPTIONS, storage: fakeStorage }
+ );
+ assert.equal(result.messages.length, 1);
+ assert.equal(result.messages[0].id, testMessage.id);
+ });
+ });
+ it("should return an empty array for a remote provider with a blank URL without attempting a request", async () => {
+ const provider = { id: "provider123", type: "remote", url: "" };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.notCalled(fetchStub);
+ assert.deepEqual(result.messages, []);
+ });
+ it("should return .lastUpdated with the time at which the messages were fetched", async () => {
+ const sourceMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "foo.com",
+ };
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () =>
+ new Promise(resolve => {
+ clock.tick(42);
+ resolve({ messages: [sourceMessage] });
+ }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.propertyVal(result, "lastUpdated", 42);
+ });
+ });
+
+ describe("#shouldProviderUpdate", () => {
+ it("should return true if the provider does not had a .lastUpdated property", () => {
+ assert.isTrue(MessageLoaderUtils.shouldProviderUpdate({ id: "foo" }));
+ });
+ it("should return false if the provider does not had a .updateCycleInMs property and has a .lastUpdated", () => {
+ clock.tick(1);
+ assert.isFalse(
+ MessageLoaderUtils.shouldProviderUpdate({ id: "foo", lastUpdated: 0 })
+ );
+ });
+ it("should return true if the time since .lastUpdated is greater than .updateCycleInMs", () => {
+ clock.tick(301);
+ assert.isTrue(
+ MessageLoaderUtils.shouldProviderUpdate({
+ id: "foo",
+ lastUpdated: 0,
+ updateCycleInMs: 300,
+ })
+ );
+ });
+ it("should return false if the time since .lastUpdated is less than .updateCycleInMs", () => {
+ clock.tick(299);
+ assert.isFalse(
+ MessageLoaderUtils.shouldProviderUpdate({
+ id: "foo",
+ lastUpdated: 0,
+ updateCycleInMs: 300,
+ })
+ );
+ });
+ });
+
+ describe("#cleanupCache", () => {
+ it("should remove data for providers no longer active", async () => {
+ const fakeStorage = {
+ get: sinon.stub().returns(
+ Promise.resolve({
+ "id-1": {},
+ "id-2": {},
+ "id-3": {},
+ })
+ ),
+ set: sinon.stub().returns(Promise.resolve()),
+ };
+ const fakeProviders = [
+ { id: "id-1", type: "remote" },
+ { id: "id-3", type: "remote" },
+ ];
+
+ await MessageLoaderUtils.cleanupCache(fakeProviders, fakeStorage);
+
+ assert.calledOnce(fakeStorage.set);
+ assert.calledWith(
+ fakeStorage.set,
+ MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY,
+ { "id-1": {}, "id-3": {} }
+ );
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx
new file mode 100644
index 0000000000..2320e16fc3
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx
@@ -0,0 +1,69 @@
+import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay";
+import { mount } from "enzyme";
+import React from "react";
+
+describe("ModalOverlayWrapper", () => {
+ let fakeDoc;
+ let sandbox;
+ let header;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ header = document.createElement("div");
+
+ fakeDoc = {
+ addEventListener: sandbox.stub(),
+ removeEventListener: sandbox.stub(),
+ body: { classList: { add: sandbox.stub(), remove: sandbox.stub() } },
+ getElementById() {
+ return header;
+ },
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should add eventListener and a class on mount", async () => {
+ mount(<ModalOverlayWrapper document={fakeDoc} />);
+ assert.calledOnce(fakeDoc.addEventListener);
+ assert.calledWith(fakeDoc.body.classList.add, "modal-open");
+ });
+
+ it("should remove eventListener on unmount", async () => {
+ const wrapper = mount(<ModalOverlayWrapper document={fakeDoc} />);
+ wrapper.unmount();
+ assert.calledOnce(fakeDoc.addEventListener);
+ assert.calledOnce(fakeDoc.removeEventListener);
+ assert.calledWith(fakeDoc.body.classList.remove, "modal-open");
+ });
+
+ it("should call props.onClose on an Escape key", async () => {
+ const onClose = sandbox.stub();
+ mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />);
+
+ // Simulate onkeydown being called
+ const [, callback] = fakeDoc.addEventListener.firstCall.args;
+ callback({ key: "Escape" });
+
+ assert.calledOnce(onClose);
+ });
+
+ it("should not call props.onClose on other keys than Escape", async () => {
+ const onClose = sandbox.stub();
+ mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />);
+
+ // Simulate onkeydown being called
+ const [, callback] = fakeDoc.addEventListener.firstCall.args;
+ callback({ key: "Ctrl" });
+
+ assert.notCalled(onClose);
+ });
+
+ it("should not call props.onClose when clicked outside dialog", async () => {
+ const onClose = sandbox.stub();
+ const wrapper = mount(
+ <ModalOverlayWrapper document={fakeDoc} onClose={onClose} />
+ );
+ wrapper.find("div.modalOverlayOuter.active").simulate("click");
+ assert.notCalled(onClose);
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/MomentsPageHub.test.js b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js
new file mode 100644
index 0000000000..63683a6849
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js
@@ -0,0 +1,336 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs";
+import { _MomentsPageHub } from "modules/MomentsPageHub.sys.mjs";
+const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once";
+
+describe("MomentsPageHub", () => {
+ let globals;
+ let sandbox;
+ let instance;
+ let handleMessageRequestStub;
+ let addImpressionStub;
+ let blockMessageByIdStub;
+ let sendTelemetryStub;
+ let getStringPrefStub;
+ let setStringPrefStub;
+ let setIntervalStub;
+ let clearIntervalStub;
+
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ instance = new _MomentsPageHub();
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ handleMessageRequestStub = sandbox.stub().resolves(messages);
+ addImpressionStub = sandbox.stub();
+ blockMessageByIdStub = sandbox.stub();
+ getStringPrefStub = sandbox.stub();
+ setStringPrefStub = sandbox.stub();
+ setIntervalStub = sandbox.stub();
+ clearIntervalStub = sandbox.stub();
+ sendTelemetryStub = sandbox.stub();
+ globals.set({
+ setInterval: setIntervalStub,
+ clearInterval: clearIntervalStub,
+ Services: {
+ prefs: {
+ getStringPref: getStringPrefStub,
+ setStringPref: setStringPrefStub,
+ },
+ telemetry: {
+ recordEvent: () => {},
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should create an instance", async () => {
+ setIntervalStub.returns(42);
+ assert.ok(instance);
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+ assert.equal(instance.state._intervalId, 42);
+ });
+
+ it("should init only once", async () => {
+ assert.notCalled(handleMessageRequestStub);
+
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+
+ assert.calledOnce(handleMessageRequestStub);
+
+ instance.uninit();
+
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+
+ assert.calledTwice(handleMessageRequestStub);
+ });
+
+ it("should uninit the instance", () => {
+ instance.uninit();
+ assert.calledOnce(clearIntervalStub);
+ });
+
+ it("should setInterval for `checkHomepageOverridePref`", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+ sandbox.stub(instance, "checkHomepageOverridePref");
+
+ assert.calledOnce(setIntervalStub);
+ assert.calledWithExactly(setIntervalStub, sinon.match.func, 5 * 60 * 1000);
+
+ assert.notCalled(instance.checkHomepageOverridePref);
+ const [cb] = setIntervalStub.firstCall.args;
+
+ cb();
+
+ assert.calledOnce(instance.checkHomepageOverridePref);
+ });
+
+ describe("#messageRequest", () => {
+ beforeEach(async () => {
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ sendTelemetry: sendTelemetryStub,
+ });
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should fetch a message with the provided trigger and template", async () => {
+ await instance.messageRequest({
+ triggerId: "trigger",
+ template: "template",
+ });
+
+ assert.calledTwice(handleMessageRequestStub);
+ assert.calledWithExactly(handleMessageRequestStub, {
+ triggerId: "trigger",
+ template: "template",
+ returnAll: true,
+ });
+ });
+ it("shouldn't do anything if no message is provided", async () => {
+ // Reset the call from `instance.init`
+ setStringPrefStub.reset();
+ handleMessageRequestStub.resolves([]);
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(setStringPrefStub);
+ });
+ it("should record telemetry events", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ assert.calledOnce(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ });
+ it("should record Reach event for the Moments page experiment", async () => {
+ const momentsMessages = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ const messages = [
+ {
+ forReachEvent: { sent: false },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ...momentsMessages,
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+ sandbox.spy(instance, "executeAction");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(global.Services.telemetry.recordEvent);
+ assert.calledOnce(instance.executeAction);
+ });
+ it("should not record the Reach event if it's already sent", async () => {
+ const messages = [
+ {
+ forReachEvent: { sent: true },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(global.Services.telemetry.recordEvent);
+ });
+ it("should not trigger the action if it's only for the Reach event", async () => {
+ const messages = [
+ {
+ forReachEvent: { sent: false },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+ sandbox.spy(instance, "executeAction");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(global.Services.telemetry.recordEvent);
+ assert.notCalled(instance.executeAction);
+ });
+ });
+ describe("executeAction", () => {
+ beforeEach(async () => {
+ blockMessageByIdStub = sandbox.stub();
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ sendTelemetry: sendTelemetryStub,
+ });
+ });
+ it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", async () => {
+ const [msg] = await handleMessageRequestStub();
+ sandbox.useFakeTimers();
+ instance.executeAction(msg);
+
+ assert.calledOnce(setStringPrefStub);
+ assert.calledWithExactly(
+ setStringPrefStub,
+ HOMEPAGE_OVERRIDE_PREF,
+ JSON.stringify({
+ message_id: msg.id,
+ url: msg.content.action.data.url,
+ expire: instance.getExpirationDate(
+ msg.content.action.data.expireDelta
+ ),
+ })
+ );
+ });
+ it("should block after taking the action", async () => {
+ const [msg] = await handleMessageRequestStub();
+ instance.executeAction(msg);
+
+ assert.calledOnce(blockMessageByIdStub);
+ assert.calledWithExactly(blockMessageByIdStub, msg.id);
+ });
+ it("should compute expire based on expireDelta", async () => {
+ sandbox.spy(instance, "getExpirationDate");
+
+ const [msg] = await handleMessageRequestStub();
+ instance.executeAction(msg);
+
+ assert.calledOnce(instance.getExpirationDate);
+ assert.calledWithExactly(
+ instance.getExpirationDate,
+ msg.content.action.data.expireDelta
+ );
+ });
+ it("should compute expire based on expireDelta", async () => {
+ sandbox.spy(instance, "getExpirationDate");
+
+ const [msg] = await handleMessageRequestStub();
+ const msgWithExpire = {
+ ...msg,
+ content: {
+ ...msg.content,
+ action: {
+ ...msg.content.action,
+ data: { ...msg.content.action.data, expire: 41 },
+ },
+ },
+ };
+ instance.executeAction(msgWithExpire);
+
+ assert.notCalled(instance.getExpirationDate);
+ assert.calledOnce(setStringPrefStub);
+ assert.calledWithExactly(
+ setStringPrefStub,
+ HOMEPAGE_OVERRIDE_PREF,
+ JSON.stringify({
+ message_id: msg.id,
+ url: msg.content.action.data.url,
+ expire: 41,
+ })
+ );
+ });
+ it("should send user telemetry", async () => {
+ const [msg] = await handleMessageRequestStub();
+ const sendUserEventTelemetrySpy = sandbox.spy(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ instance.executeAction(msg);
+
+ assert.calledOnce(sendTelemetryStub);
+ assert.calledWithExactly(sendUserEventTelemetrySpy, msg);
+ assert.calledWithExactly(sendTelemetryStub, {
+ type: "MOMENTS_PAGE_TELEMETRY",
+ data: {
+ action: "moments_user_event",
+ bucket_id: "WNP_THANK_YOU",
+ event: "MOMENTS_PAGE_SET",
+ message_id: "WNP_THANK_YOU",
+ },
+ });
+ });
+ });
+ describe("#checkHomepageOverridePref", () => {
+ let messageRequestStub;
+ beforeEach(() => {
+ messageRequestStub = sandbox.stub(instance, "messageRequest");
+ });
+ it("should catch parse errors", () => {
+ getStringPrefStub.returns({});
+
+ instance.checkHomepageOverridePref();
+
+ assert.calledOnce(messageRequestStub);
+ assert.calledWithExactly(messageRequestStub, {
+ template: "update_action",
+ triggerId: "momentsUpdate",
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/RemoteL10n.test.js b/browser/components/asrouter/tests/unit/RemoteL10n.test.js
new file mode 100644
index 0000000000..dd0f858750
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/RemoteL10n.test.js
@@ -0,0 +1,217 @@
+import { RemoteL10n, _RemoteL10n } from "modules/RemoteL10n.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("RemoteL10n", () => {
+ let sandbox;
+ let globals;
+ let domL10nStub;
+ let l10nRegStub;
+ let l10nRegInstance;
+ let fileSourceStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ domL10nStub = sandbox.stub();
+ l10nRegInstance = {
+ hasSource: sandbox.stub(),
+ registerSources: sandbox.stub(),
+ removeSources: sandbox.stub(),
+ };
+
+ fileSourceStub = sandbox.stub();
+ l10nRegStub = {
+ getInstance: () => {
+ return l10nRegInstance;
+ },
+ };
+ globals.set("DOMLocalization", domL10nStub);
+ globals.set("L10nRegistry", l10nRegStub);
+ globals.set("L10nFileSource", fileSourceStub);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+ describe("#RemoteL10n", () => {
+ it("should create a new instance", () => {
+ assert.ok(new _RemoteL10n());
+ });
+ it("should create a DOMLocalization instance", () => {
+ domL10nStub.returns({ instance: true });
+ const instance = new _RemoteL10n();
+
+ assert.propertyVal(instance._createDOML10n(), "instance", true);
+ assert.calledOnce(domL10nStub);
+ });
+ it("should create a new instance", () => {
+ domL10nStub.returns({ instance: true });
+ const instance = new _RemoteL10n();
+
+ assert.ok(instance.l10n);
+
+ instance.reloadL10n();
+
+ assert.ok(instance.l10n);
+
+ assert.calledTwice(domL10nStub);
+ });
+ it("should reuse the instance", () => {
+ domL10nStub.returns({ instance: true });
+ const instance = new _RemoteL10n();
+
+ assert.ok(instance.l10n);
+ assert.ok(instance.l10n);
+
+ assert.calledOnce(domL10nStub);
+ });
+ });
+ describe("#_createDOML10n", () => {
+ it("should load the remote Fluent file if USE_REMOTE_L10N_PREF is true", async () => {
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ l10nRegInstance.hasSource.returns(false);
+ RemoteL10n._createDOML10n();
+
+ assert.calledOnce(domL10nStub);
+ const { args } = domL10nStub.firstCall;
+ // The first arg is the resource array,
+ // the second one is false (use async),
+ // and the third one is the bundle generator.
+ assert.equal(args.length, 2);
+ assert.deepEqual(args[0], [
+ "branding/brand.ftl",
+ "browser/defaultBrowserNotification.ftl",
+ "browser/newtab/asrouter.ftl",
+ "toolkit/branding/accounts.ftl",
+ "toolkit/branding/brandings.ftl",
+ ]);
+ assert.isFalse(args[1]);
+ assert.calledOnce(l10nRegInstance.hasSource);
+ assert.calledOnce(l10nRegInstance.registerSources);
+ assert.notCalled(l10nRegInstance.removeSources);
+ });
+ it("should load the local Fluent file if USE_REMOTE_L10N_PREF is false", () => {
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(false);
+ l10nRegInstance.hasSource.returns(true);
+ RemoteL10n._createDOML10n();
+
+ const { args } = domL10nStub.firstCall;
+ // The first arg is the resource array,
+ // the second one is false (use async),
+ // and the third one is null.
+ assert.equal(args.length, 2);
+ assert.deepEqual(args[0], [
+ "branding/brand.ftl",
+ "browser/defaultBrowserNotification.ftl",
+ "browser/newtab/asrouter.ftl",
+ "toolkit/branding/accounts.ftl",
+ "toolkit/branding/brandings.ftl",
+ ]);
+ assert.isFalse(args[1]);
+ assert.calledOnce(l10nRegInstance.hasSource);
+ assert.notCalled(l10nRegInstance.registerSources);
+ assert.calledOnce(l10nRegInstance.removeSources);
+ });
+ });
+ describe("#createElement", () => {
+ let doc;
+ let instance;
+ let setStringStub;
+ let elem;
+ beforeEach(() => {
+ elem = document.createElement("div");
+ doc = {
+ createElement: sandbox.stub().returns(elem),
+ createElementNS: sandbox.stub().returns(elem),
+ };
+ instance = new _RemoteL10n();
+ setStringStub = sandbox.stub(instance, "setString");
+ });
+ it("should call createElement if string_id is defined", () => {
+ instance.createElement(doc, "span", { content: { string_id: "foo" } });
+
+ assert.calledOnce(doc.createElement);
+ });
+ it("should call createElementNS if string_id is not present", () => {
+ instance.createElement(doc, "span", { content: "foo" });
+
+ assert.calledOnce(doc.createElementNS);
+ });
+ it("should set classList", () => {
+ instance.createElement(doc, "span", { classList: "foo" });
+
+ assert.isTrue(elem.classList.contains("foo"));
+ });
+ it("should call setString", () => {
+ const options = { classList: "foo" };
+ instance.createElement(doc, "span", options);
+
+ assert.calledOnce(setStringStub);
+ assert.calledWithExactly(setStringStub, elem, options);
+ });
+ });
+ describe("#setString", () => {
+ let instance;
+ beforeEach(() => {
+ instance = new _RemoteL10n();
+ });
+ it("should set fluent variables and id", () => {
+ let el = { setAttribute: sandbox.stub() };
+ instance.setString(el, {
+ content: { string_id: "foo" },
+ attributes: { bar: "bar", baz: "baz" },
+ });
+
+ assert.calledThrice(el.setAttribute);
+ assert.calledWithExactly(el.setAttribute, "fluent-variable-bar", "bar");
+ assert.calledWithExactly(el.setAttribute, "fluent-variable-baz", "baz");
+ assert.calledWithExactly(el.setAttribute, "fluent-remote-id", "foo");
+ });
+ it("should set content if no string_id", () => {
+ let el = { setAttribute: sandbox.stub() };
+ instance.setString(el, { content: "foo" });
+
+ assert.notCalled(el.setAttribute);
+ assert.equal(el.textContent, "foo");
+ });
+ });
+ describe("#isLocaleSupported", () => {
+ it("should return true if the locale is en-US", () => {
+ assert.ok(RemoteL10n.isLocaleSupported("en-US"));
+ });
+ it("should return true if the locale is in all-locales", () => {
+ assert.ok(RemoteL10n.isLocaleSupported("en-CA"));
+ });
+ it("should return false if the locale is not in all-locales", () => {
+ assert.ok(!RemoteL10n.isLocaleSupported("und"));
+ });
+ });
+ describe("#formatLocalizableText", () => {
+ let instance;
+ let formatValueStub;
+ beforeEach(() => {
+ instance = new _RemoteL10n();
+ formatValueStub = sandbox.stub();
+ sandbox
+ .stub(instance, "l10n")
+ .get(() => ({ formatValue: formatValueStub }));
+ });
+ it("should localize a string_id", async () => {
+ formatValueStub.resolves("VALUE");
+
+ assert.equal(
+ await instance.formatLocalizableText({ string_id: "ID" }),
+ "VALUE"
+ );
+ assert.calledOnce(formatValueStub);
+ });
+ it("should pass through a string", async () => {
+ formatValueStub.reset();
+
+ assert.equal(
+ await instance.formatLocalizableText("unchanged"),
+ "unchanged"
+ );
+ assert.isFalse(formatValueStub.called);
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/TargetingDocs.test.js b/browser/components/asrouter/tests/unit/TargetingDocs.test.js
new file mode 100644
index 0000000000..d00f971453
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/TargetingDocs.test.js
@@ -0,0 +1,88 @@
+import { ASRouterTargeting } from "modules/ASRouterTargeting.sys.mjs";
+import docs from "docs/targeting-attributes.md";
+
+// The following targeting parameters are either deprecated or should not be included in the docs for some reason.
+const SKIP_DOCS = [];
+// These are extra message context attributes via ASRouter.sys.mjs
+const MESSAGE_CONTEXT_ATTRIBUTES = ["previousSessionEnd"];
+
+function getHeadingsFromDocs() {
+ const re = /### `(\w+)`/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re.exec(docs);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+function getTOCFromDocs() {
+ const re = /## Available attributes\n+([^]+)\n+## Detailed usage/;
+ const sectionMatch = docs.match(re);
+ if (!sectionMatch) {
+ return [];
+ }
+ const [, listText] = sectionMatch;
+ const re2 = /\[(\w+)\]/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re2.exec(listText);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+describe("ASRTargeting docs", () => {
+ const DOCS_TARGETING_HEADINGS = getHeadingsFromDocs();
+ const DOCS_TOC = getTOCFromDocs();
+ const ASRTargetingAttributes = [
+ ...Object.keys(ASRouterTargeting.Environment).filter(
+ attribute => !SKIP_DOCS.includes(attribute)
+ ),
+ ...MESSAGE_CONTEXT_ATTRIBUTES,
+ ];
+
+ describe("All targeting params documented in targeting-attributes.md", () => {
+ for (const targetingParam of ASRTargetingAttributes) {
+ // If this test is failing, you probably forgot to add docs to content-src/asrouter/targeting-attributes.md
+ // for a new targeting attribute, or you forgot to put it in the table of contents up top.
+ it(`should have docs and table of contents entry for ${targetingParam}`, () => {
+ assert.include(
+ DOCS_TARGETING_HEADINGS,
+ targetingParam,
+ `Didn't find the heading: ### \`${targetingParam}\``
+ );
+ assert.include(
+ DOCS_TOC,
+ targetingParam,
+ `Didn't find a table of contents entry for ${targetingParam}`
+ );
+ });
+ }
+ });
+ describe("No extra attributes in targeting-attributes.md", () => {
+ // "allow" includes targeting attributes that are not implemented by
+ // ASRTargetingAttributes. For example trigger context passed to the evaluation
+ // context in when a trigger runs or ASRouter state used in the evaluation.
+ const allow = ["messageImpressions", "screenImpressions"];
+ for (const targetingParam of DOCS_TARGETING_HEADINGS.filter(
+ doc => !allow.includes(doc)
+ )) {
+ // If this test is failing, you might have spelled something wrong or removed a targeting param without
+ // removing its docs.
+ it(`should have an implementation for ${targetingParam} in ASRouterTargeting.Environment`, () => {
+ assert.include(
+ ASRTargetingAttributes,
+ targetingParam,
+ `Didn't find an implementation for ${targetingParam}`
+ );
+ });
+ }
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js
new file mode 100644
index 0000000000..3e91b657bc
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js
@@ -0,0 +1,652 @@
+import { _ToolbarBadgeHub } from "modules/ToolbarBadgeHub.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs";
+import {
+ _ToolbarPanelHub,
+ ToolbarPanelHub,
+} from "modules/ToolbarPanelHub.sys.mjs";
+
+describe("ToolbarBadgeHub", () => {
+ let sandbox;
+ let instance;
+ let fakeAddImpression;
+ let fakeSendTelemetry;
+ let isBrowserPrivateStub;
+ let fxaMessage;
+ let whatsnewMessage;
+ let fakeElement;
+ let globals;
+ let everyWindowStub;
+ let clearTimeoutStub;
+ let setTimeoutStub;
+ let addObserverStub;
+ let removeObserverStub;
+ let getStringPrefStub;
+ let clearUserPrefStub;
+ let setStringPrefStub;
+ let requestIdleCallbackStub;
+ let fakeWindow;
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ instance = new _ToolbarBadgeHub();
+ fakeAddImpression = sandbox.stub();
+ fakeSendTelemetry = sandbox.stub();
+ isBrowserPrivateStub = sandbox.stub();
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE");
+ whatsnewMessage = {
+ id: `WHATS_NEW_BADGE_71`,
+ template: "toolbar_badge",
+ content: {
+ delay: 1000,
+ target: "whats-new-menu-button",
+ action: { id: "show-whatsnew-button" },
+ badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" },
+ },
+ priority: 1,
+ trigger: { id: "toolbarBadgeUpdate" },
+ frequency: {
+ // Makes it so that we track impressions for this message while at the
+ // same time it can have unlimited impressions
+ lifetime: Infinity,
+ },
+ // Never saw this message or saw it in the past 4 days or more recent
+ targeting: `isWhatsNewPanelEnabled &&
+ (!messageImpressions['WHATS_NEW_BADGE_71'] ||
+ (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 &&
+ currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`,
+ };
+ fakeElement = {
+ classList: {
+ add: sandbox.stub(),
+ remove: sandbox.stub(),
+ },
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ remove: sandbox.stub(),
+ appendChild: sandbox.stub(),
+ };
+ // Share the same element when selecting child nodes
+ fakeElement.querySelector.returns(fakeElement);
+ everyWindowStub = {
+ registerCallback: sandbox.stub(),
+ unregisterCallback: sandbox.stub(),
+ };
+ clearTimeoutStub = sandbox.stub();
+ setTimeoutStub = sandbox.stub();
+ fakeWindow = {
+ MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
+ ownerGlobal: {
+ gBrowser: {
+ selectedBrowser: "browser",
+ },
+ },
+ };
+ addObserverStub = sandbox.stub();
+ removeObserverStub = sandbox.stub();
+ getStringPrefStub = sandbox.stub();
+ clearUserPrefStub = sandbox.stub();
+ setStringPrefStub = sandbox.stub();
+ requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn());
+ globals.set({
+ ToolbarPanelHub,
+ requestIdleCallback: requestIdleCallbackStub,
+ EveryWindow: everyWindowStub,
+ PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub },
+ setTimeout: setTimeoutStub,
+ clearTimeout: clearTimeoutStub,
+ Services: {
+ wm: {
+ getMostRecentWindow: () => fakeWindow,
+ },
+ prefs: {
+ addObserver: addObserverStub,
+ removeObserver: removeObserverStub,
+ getStringPref: getStringPrefStub,
+ clearUserPref: clearUserPrefStub,
+ setStringPref: setStringPrefStub,
+ },
+ },
+ });
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+ it("should create an instance", () => {
+ assert.ok(instance);
+ });
+ describe("#init", () => {
+ it("should make a single messageRequest on init", async () => {
+ sandbox.stub(instance, "messageRequest");
+ const waitForInitialized = sandbox.stub().resolves();
+
+ await instance.init(waitForInitialized, {});
+ await instance.init(waitForInitialized, {});
+ assert.calledOnce(instance.messageRequest);
+ assert.calledWithExactly(instance.messageRequest, {
+ template: "toolbar_badge",
+ triggerId: "toolbarBadgeUpdate",
+ });
+
+ instance.uninit();
+
+ await instance.init(waitForInitialized, {});
+
+ assert.calledTwice(instance.messageRequest);
+ });
+ it("should add a pref observer", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+
+ assert.calledOnce(addObserverStub);
+ assert.calledWithExactly(
+ addObserverStub,
+ instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+ instance
+ );
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+ });
+ it("should clear any setTimeout cbs", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+
+ instance.state.showBadgeTimeoutId = 2;
+
+ instance.uninit();
+
+ assert.calledOnce(clearTimeoutStub);
+ assert.calledWithExactly(clearTimeoutStub, 2);
+ });
+ it("should remove the pref observer", () => {
+ instance.uninit();
+
+ assert.calledOnce(removeObserverStub);
+ assert.calledWithExactly(
+ removeObserverStub,
+ instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+ instance
+ );
+ });
+ });
+ describe("messageRequest", () => {
+ let handleMessageRequestStub;
+ beforeEach(() => {
+ handleMessageRequestStub = sandbox.stub().returns(fxaMessage);
+ sandbox
+ .stub(instance, "_handleMessageRequest")
+ .value(handleMessageRequestStub);
+ sandbox.stub(instance, "registerBadgeNotificationListener");
+ });
+ it("should fetch a message with the provided trigger and template", async () => {
+ await instance.messageRequest({
+ triggerId: "trigger",
+ template: "template",
+ });
+
+ assert.calledOnce(handleMessageRequestStub);
+ assert.calledWithExactly(handleMessageRequestStub, {
+ triggerId: "trigger",
+ template: "template",
+ });
+ });
+ it("should call addToolbarNotification with browser window and message", async () => {
+ await instance.messageRequest("trigger");
+
+ assert.calledOnce(instance.registerBadgeNotificationListener);
+ assert.calledWithExactly(
+ instance.registerBadgeNotificationListener,
+ fxaMessage
+ );
+ });
+ it("shouldn't do anything if no message is provided", async () => {
+ handleMessageRequestStub.resolves(null);
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(instance.registerBadgeNotificationListener);
+ });
+ it("should record telemetry events", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+ handleMessageRequestStub.returns(null);
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ assert.calledOnce(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ });
+ });
+ describe("addToolbarNotification", () => {
+ let target;
+ let fakeDocument;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ fakeDocument = {
+ getElementById: sandbox.stub().returns(fakeElement),
+ createElement: sandbox.stub().returns(fakeElement),
+ l10n: { setAttributes: sandbox.stub() },
+ };
+ target = { ...fakeWindow, browser: { ownerDocument: fakeDocument } };
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("shouldn't do anything if target element is not found", () => {
+ fakeDocument.getElementById.returns(null);
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.notCalled(fakeElement.setAttribute);
+ });
+ it("should target the element specified in the message", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledOnce(fakeDocument.getElementById);
+ assert.calledWithExactly(
+ fakeDocument.getElementById,
+ fxaMessage.content.target
+ );
+ });
+ it("should show a notification", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledOnce(fakeElement.setAttribute);
+ assert.calledWithExactly(fakeElement.setAttribute, "badged", true);
+ assert.calledWithExactly(fakeElement.classList.add, "feature-callout");
+ });
+ it("should attach a cb on the notification", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledTwice(fakeElement.addEventListener);
+ assert.calledWithExactly(
+ fakeElement.addEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeElement.addEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ it("should execute actions if they exist", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance.executeAction);
+ assert.calledWithExactly(instance.executeAction, {
+ ...whatsnewMessage.content.action,
+ message_id: whatsnewMessage.id,
+ });
+ });
+ it("should create a description element", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(fakeDocument.createElement);
+ assert.calledWithExactly(fakeDocument.createElement, "span");
+ });
+ it("should set description id to element and to button", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledWithExactly(
+ fakeElement.setAttribute,
+ "id",
+ "toolbarbutton-notification-description"
+ );
+ assert.calledWithExactly(
+ fakeElement.setAttribute,
+ "aria-labelledby",
+ `toolbarbutton-notification-description ${whatsnewMessage.content.target}`
+ );
+ });
+ it("should attach fluent id to description", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(fakeDocument.l10n.setAttributes);
+ assert.calledWithExactly(
+ fakeDocument.l10n.setAttributes,
+ fakeElement,
+ whatsnewMessage.content.badgeDescription.string_id
+ );
+ });
+ it("should add an impression for the message", () => {
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance._addImpression);
+ assert.calledWithExactly(instance._addImpression, whatsnewMessage);
+ });
+ it("should send an impression ping", async () => {
+ sandbox.stub(instance, "sendUserEventTelemetry");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance.sendUserEventTelemetry);
+ assert.calledWithExactly(
+ instance.sendUserEventTelemetry,
+ "IMPRESSION",
+ whatsnewMessage
+ );
+ });
+ });
+ describe("registerBadgeNotificationListener", () => {
+ let msg_no_delay;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ sandbox.stub(instance, "addToolbarNotification").returns(fakeElement);
+ sandbox.stub(instance, "removeToolbarNotification");
+ msg_no_delay = {
+ ...fxaMessage,
+ content: {
+ ...fxaMessage.content,
+ delay: 0,
+ },
+ };
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should register a callback that adds/removes the notification", () => {
+ instance.registerBadgeNotificationListener(msg_no_delay);
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ instance.id,
+ sinon.match.func,
+ sinon.match.func
+ );
+
+ const [, initFn, uninitFn] =
+ everyWindowStub.registerCallback.firstCall.args;
+
+ initFn(window);
+ // Test that it doesn't try to add a second notification
+ initFn(window);
+
+ assert.calledOnce(instance.addToolbarNotification);
+ assert.calledWithExactly(
+ instance.addToolbarNotification,
+ window,
+ msg_no_delay
+ );
+
+ uninitFn(window);
+
+ assert.calledOnce(instance.removeToolbarNotification);
+ assert.calledWithExactly(instance.removeToolbarNotification, fakeElement);
+ });
+ it("should unregister notifications when forcing a badge via devtools", () => {
+ instance.registerBadgeNotificationListener(msg_no_delay, { force: true });
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+ });
+ it("should only call executeAction for 'update_action' messages", () => {
+ const stub = sandbox.stub(instance, "executeAction");
+ const updateActionMsg = { ...msg_no_delay, template: "update_action" };
+
+ instance.registerBadgeNotificationListener(updateActionMsg);
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ assert.calledOnce(stub);
+ });
+ });
+ describe("executeAction", () => {
+ let blockMessageByIdStub;
+ beforeEach(async () => {
+ blockMessageByIdStub = sandbox.stub();
+ await instance.init(sandbox.stub().resolves(), {
+ blockMessageById: blockMessageByIdStub,
+ });
+ });
+ it("should call ToolbarPanelHub.enableToolbarButton", () => {
+ const stub = sandbox.stub(
+ _ToolbarPanelHub.prototype,
+ "enableToolbarButton"
+ );
+
+ instance.executeAction({ id: "show-whatsnew-button" });
+
+ assert.calledOnce(stub);
+ });
+ it("should call ToolbarPanelHub.enableAppmenuButton", () => {
+ const stub = sandbox.stub(
+ _ToolbarPanelHub.prototype,
+ "enableAppmenuButton"
+ );
+
+ instance.executeAction({ id: "show-whatsnew-button" });
+
+ assert.calledOnce(stub);
+ });
+ });
+ describe("removeToolbarNotification", () => {
+ it("should remove the notification", () => {
+ instance.removeToolbarNotification(fakeElement);
+
+ assert.calledThrice(fakeElement.removeAttribute);
+ assert.calledWithExactly(fakeElement.removeAttribute, "badged");
+ assert.calledWithExactly(fakeElement.removeAttribute, "aria-labelledby");
+ assert.calledWithExactly(fakeElement.removeAttribute, "aria-describedby");
+ assert.calledOnce(fakeElement.classList.remove);
+ assert.calledWithExactly(fakeElement.classList.remove, "feature-callout");
+ assert.calledOnce(fakeElement.remove);
+ });
+ });
+ describe("removeAllNotifications", () => {
+ let blockMessageByIdStub;
+ let fakeEvent;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ sendTelemetry: fakeSendTelemetry,
+ });
+ blockMessageByIdStub = sandbox.stub();
+ sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub);
+ instance.state = { notification: { id: fxaMessage.id } };
+ fakeEvent = { target: { removeEventListener: sandbox.stub() } };
+ });
+ it("should call to block the message", () => {
+ instance.removeAllNotifications();
+
+ assert.calledOnce(blockMessageByIdStub);
+ assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id);
+ });
+ it("should remove the window listener", () => {
+ instance.removeAllNotifications();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+ });
+ it("should ignore right mouse button (mousedown event)", () => {
+ fakeEvent.type = "mousedown";
+ fakeEvent.button = 1; // not left click
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should ignore right mouse button (click event)", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 1; // not left click
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should ignore keypresses that are not meant to focus the target", () => {
+ fakeEvent.type = "keypress";
+ fakeEvent.key = "\t"; // not enter
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should remove the event listeners after succesfully focusing the element", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 0;
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledTwice(fakeEvent.target.removeEventListener);
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ it("should send telemetry", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 0;
+ sandbox.stub(instance, "sendUserEventTelemetry");
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledOnce(instance.sendUserEventTelemetry);
+ assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", {
+ id: "FXA_ACCOUNTS_BADGE",
+ });
+ });
+ it("should remove the event listeners after succesfully focusing the element", () => {
+ fakeEvent.type = "keypress";
+ fakeEvent.key = "Enter";
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledTwice(fakeEvent.target.removeEventListener);
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ });
+ describe("message with delay", () => {
+ let msg_with_delay;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ });
+ msg_with_delay = {
+ ...fxaMessage,
+ content: {
+ ...fxaMessage.content,
+ delay: 500,
+ },
+ };
+ sandbox.stub(instance, "registerBadgeToAllWindows");
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should register a cb to fire after msg.content.delay ms", () => {
+ instance.registerBadgeNotificationListener(msg_with_delay);
+
+ assert.calledOnce(setTimeoutStub);
+ assert.calledWithExactly(
+ setTimeoutStub,
+ sinon.match.func,
+ msg_with_delay.content.delay
+ );
+
+ const [cb] = setTimeoutStub.firstCall.args;
+
+ assert.notCalled(instance.registerBadgeToAllWindows);
+
+ cb();
+
+ assert.calledOnce(instance.registerBadgeToAllWindows);
+ assert.calledWithExactly(
+ instance.registerBadgeToAllWindows,
+ msg_with_delay
+ );
+ // Delayed actions should be executed inside requestIdleCallback
+ assert.calledOnce(requestIdleCallbackStub);
+ });
+ });
+ describe("#sendUserEventTelemetry", () => {
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ sendTelemetry: fakeSendTelemetry,
+ });
+ });
+ it("should check for private window and not send", () => {
+ isBrowserPrivateStub.returns(true);
+
+ instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+ assert.notCalled(instance._sendTelemetry);
+ });
+ it("should check for private window and send", () => {
+ isBrowserPrivateStub.returns(false);
+
+ instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+ assert.calledOnce(fakeSendTelemetry);
+ const [ping] = instance._sendTelemetry.firstCall.args;
+ assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY");
+ assert.propertyVal(ping.data, "event", "CLICK");
+ });
+ });
+ describe("#observe", () => {
+ it("should make a message request when the whats new pref is changed", () => {
+ sandbox.stub(instance, "messageRequest");
+
+ instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL);
+
+ assert.calledOnce(instance.messageRequest);
+ assert.calledWithExactly(instance.messageRequest, {
+ template: "toolbar_badge",
+ triggerId: "toolbarBadgeUpdate",
+ });
+ });
+ it("should not react to other pref changes", () => {
+ sandbox.stub(instance, "messageRequest");
+
+ instance.observe("", "", "foo");
+
+ assert.notCalled(instance.messageRequest);
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js
new file mode 100644
index 0000000000..64cb8243b7
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js
@@ -0,0 +1,762 @@
+import { _ToolbarPanelHub } from "modules/ToolbarPanelHub.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs";
+
+describe("ToolbarPanelHub", () => {
+ let globals;
+ let sandbox;
+ let instance;
+ let everyWindowStub;
+ let fakeDocument;
+ let fakeWindow;
+ let fakeElementById;
+ let fakeElementByTagName;
+ let createdCustomElements = [];
+ let eventListeners = {};
+ let addObserverStub;
+ let removeObserverStub;
+ let getBoolPrefStub;
+ let setBoolPrefStub;
+ let waitForInitializedStub;
+ let isBrowserPrivateStub;
+ let fakeSendTelemetry;
+ let getEarliestRecordedDateStub;
+ let getEventsByDateRangeStub;
+ let defaultSearchStub;
+ let scriptloaderStub;
+ let fakeRemoteL10n;
+ let getViewNodeStub;
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ instance = new _ToolbarPanelHub();
+ waitForInitializedStub = sandbox.stub().resolves();
+ fakeElementById = {
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub().returns(null),
+ querySelectorAll: sandbox.stub().returns([]),
+ appendChild: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ hasAttribute: sandbox.stub(),
+ toggleAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ removeChild: sandbox.stub(),
+ };
+ fakeElementByTagName = {
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub().returns(null),
+ querySelectorAll: sandbox.stub().returns([]),
+ appendChild: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ hasAttribute: sandbox.stub(),
+ toggleAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ removeChild: sandbox.stub(),
+ };
+ fakeDocument = {
+ getElementById: sandbox.stub().returns(fakeElementById),
+ getElementsByTagName: sandbox.stub().returns(fakeElementByTagName),
+ querySelector: sandbox.stub().returns({}),
+ createElement: tagName => {
+ const element = {
+ tagName,
+ classList: {},
+ addEventListener: (ev, fn) => {
+ eventListeners[ev] = fn;
+ },
+ appendChild: sandbox.stub(),
+ setAttribute: sandbox.stub(),
+ textContent: "",
+ };
+ element.classList.add = sandbox.stub();
+ element.classList.includes = className =>
+ element.classList.add.firstCall.args[0] === className;
+ createdCustomElements.push(element);
+ return element;
+ },
+ l10n: {
+ translateElements: sandbox.stub(),
+ translateFragment: sandbox.stub(),
+ formatMessages: sandbox.stub().resolves([{}]),
+ setAttributes: sandbox.stub(),
+ },
+ };
+ fakeWindow = {
+ // eslint-disable-next-line object-shorthand
+ DocumentFragment: function () {
+ return fakeElementById;
+ },
+ document: fakeDocument,
+ browser: {
+ ownerDocument: fakeDocument,
+ },
+ MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
+ ownerGlobal: {
+ openLinkIn: sandbox.stub(),
+ gBrowser: "gBrowser",
+ },
+ PanelUI: {
+ panel: fakeElementById,
+ whatsNewPanel: fakeElementById,
+ },
+ customElements: { get: sandbox.stub() },
+ };
+ everyWindowStub = {
+ registerCallback: sandbox.stub(),
+ unregisterCallback: sandbox.stub(),
+ };
+ scriptloaderStub = { loadSubScript: sandbox.stub() };
+ addObserverStub = sandbox.stub();
+ removeObserverStub = sandbox.stub();
+ getBoolPrefStub = sandbox.stub();
+ setBoolPrefStub = sandbox.stub();
+ fakeSendTelemetry = sandbox.stub();
+ isBrowserPrivateStub = sandbox.stub();
+ getEarliestRecordedDateStub = sandbox.stub().returns(
+ // A random date that's not the current timestamp
+ new Date() - 500
+ );
+ getEventsByDateRangeStub = sandbox.stub().returns([]);
+ getViewNodeStub = sandbox.stub().returns(fakeElementById);
+ defaultSearchStub = { defaultEngine: { name: "DDG" } };
+ fakeRemoteL10n = {
+ l10n: {},
+ reloadL10n: sandbox.stub(),
+ createElement: sandbox
+ .stub()
+ .callsFake((doc, el) => fakeDocument.createElement(el)),
+ };
+ globals.set({
+ EveryWindow: everyWindowStub,
+ Services: {
+ ...Services,
+ prefs: {
+ addObserver: addObserverStub,
+ removeObserver: removeObserverStub,
+ getBoolPref: getBoolPrefStub,
+ setBoolPref: setBoolPrefStub,
+ },
+ search: defaultSearchStub,
+ scriptloader: scriptloaderStub,
+ },
+ PrivateBrowsingUtils: {
+ isBrowserPrivate: isBrowserPrivateStub,
+ },
+ TrackingDBService: {
+ getEarliestRecordedDate: getEarliestRecordedDateStub,
+ getEventsByDateRange: getEventsByDateRangeStub,
+ },
+ SpecialMessageActions: {
+ handleAction: sandbox.stub(),
+ },
+ RemoteL10n: fakeRemoteL10n,
+ PanelMultiView: {
+ getViewNode: getViewNodeStub,
+ },
+ });
+ });
+ afterEach(() => {
+ instance.uninit();
+ sandbox.restore();
+ globals.restore();
+ eventListeners = {};
+ createdCustomElements = [];
+ });
+ it("should create an instance", () => {
+ assert.ok(instance);
+ });
+ it("should enableAppmenuButton() on init() just once", async () => {
+ instance.enableAppmenuButton = sandbox.stub();
+
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+
+ assert.calledOnce(instance.enableAppmenuButton);
+
+ instance.uninit();
+
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+
+ assert.calledTwice(instance.enableAppmenuButton);
+ });
+ it("should unregisterCallback on uninit()", () => {
+ instance.uninit();
+ assert.calledTwice(everyWindowStub.unregisterCallback);
+ });
+ describe("#maybeLoadCustomElement", () => {
+ it("should not load customElements a second time", () => {
+ instance.maybeLoadCustomElement({ customElements: new Map() });
+ instance.maybeLoadCustomElement({
+ customElements: new Map([["remote-text", true]]),
+ });
+
+ assert.calledOnce(scriptloaderStub.loadSubScript);
+ });
+ });
+ describe("#toggleWhatsNewPref", () => {
+ it("should call Services.prefs.setBoolPref() with the opposite value", () => {
+ let checkbox = {};
+ let event = { target: checkbox };
+ // checkbox starts false
+ checkbox.checked = false;
+
+ // toggling the checkbox to set the value to true;
+ // Preferences.set() gets called before the checkbox changes,
+ // so we have to call it with the opposite value.
+ instance.toggleWhatsNewPref(event);
+
+ assert.calledOnce(setBoolPrefStub);
+ assert.calledWith(
+ setBoolPrefStub,
+ "browser.messaging-system.whatsNewPanel.enabled",
+ !checkbox.checked
+ );
+ });
+ it("should report telemetry with the opposite value", () => {
+ let sendUserEventTelemetryStub = sandbox.stub(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ let event = {
+ target: { checked: true, ownerGlobal: fakeWindow },
+ };
+
+ instance.toggleWhatsNewPref(event);
+
+ assert.calledOnce(sendUserEventTelemetryStub);
+ const { args } = sendUserEventTelemetryStub.firstCall;
+ assert.equal(args[1], "WNP_PREF_TOGGLE");
+ assert.propertyVal(args[3].value, "prefValue", false);
+ });
+ });
+ describe("#enableAppmenuButton", () => {
+ it("should registerCallback on enableAppmenuButton() if there are messages", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([{}, {}]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableAppmenuButton();
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ "appMenu-whatsnew-button",
+ sinon.match.func,
+ sinon.match.func
+ );
+ });
+ it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => {
+ instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableAppmenuButton();
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ });
+ });
+ describe("#disableAppmenuButton", () => {
+ it("should call the unregisterCallback", () => {
+ assert.notCalled(everyWindowStub.unregisterCallback);
+
+ instance.disableAppmenuButton();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(
+ everyWindowStub.unregisterCallback,
+ "appMenu-whatsnew-button"
+ );
+ });
+ });
+ describe("#enableToolbarButton", () => {
+ it("should registerCallback on enableToolbarButton if messages.length", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([{}, {}]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableToolbarButton();
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ "whats-new-menu-button",
+ sinon.match.func,
+ sinon.match.func
+ );
+ });
+ it("should not registerCallback on enableToolbarButton if no messages", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([]),
+ });
+
+ await instance.enableToolbarButton();
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ });
+ });
+ describe("Show/Hide functions", () => {
+ it("should unhide appmenu button on _showAppmenuButton()", async () => {
+ await instance._showAppmenuButton(fakeWindow);
+
+ assert.equal(fakeElementById.hidden, false);
+ });
+ it("should hide appmenu button on _hideAppmenuButton()", () => {
+ instance._hideAppmenuButton(fakeWindow);
+ assert.equal(fakeElementById.hidden, true);
+ });
+ it("should not do anything if the window is closed", () => {
+ instance._hideAppmenuButton(fakeWindow, true);
+ assert.notCalled(global.PanelMultiView.getViewNode);
+ });
+ it("should not throw if the element does not exist", () => {
+ let fn = instance._hideAppmenuButton.bind(null, {
+ browser: { ownerDocument: {} },
+ });
+ getViewNodeStub.returns(undefined);
+ assert.doesNotThrow(fn);
+ });
+ it("should unhide toolbar button on _showToolbarButton()", async () => {
+ await instance._showToolbarButton(fakeWindow);
+
+ assert.equal(fakeElementById.hidden, false);
+ });
+ it("should hide toolbar button on _hideToolbarButton()", () => {
+ instance._hideToolbarButton(fakeWindow);
+ assert.equal(fakeElementById.hidden, true);
+ });
+ });
+ describe("#renderMessages", () => {
+ let getMessagesStub;
+ beforeEach(() => {
+ getMessagesStub = sandbox.stub();
+ instance.init(waitForInitializedStub, {
+ getMessages: getMessagesStub,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ });
+ it("should have correct state", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+
+ getMessagesStub.returns(messages);
+ const ev1 = sandbox.stub();
+ ev1.withArgs("type").returns(1); // tracker
+ ev1.withArgs("count").returns(4);
+ const ev2 = sandbox.stub();
+ ev2.withArgs("type").returns(4); // fingerprinter
+ ev2.withArgs("count").returns(3);
+ getEventsByDateRangeStub.returns([
+ { getResultByName: ev1 },
+ { getResultByName: ev2 },
+ ]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.propertyVal(instance.state.contentArguments, "trackerCount", 4);
+ assert.propertyVal(
+ instance.state.contentArguments,
+ "fingerprinterCount",
+ 3
+ );
+ });
+ it("should render messages to the panel on renderMessages()", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ messages[0].content.link_text = { string_id: "link_text_id" };
+
+ getMessagesStub.returns(messages);
+ const ev1 = sandbox.stub();
+ ev1.withArgs("type").returns(1); // tracker
+ ev1.withArgs("count").returns(4);
+ const ev2 = sandbox.stub();
+ ev2.withArgs("type").returns(4); // fingerprinter
+ ev2.withArgs("count").returns(3);
+ getEventsByDateRangeStub.returns([
+ { getResultByName: ev1 },
+ { getResultByName: ev2 },
+ ]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ for (let message of messages) {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-title"
+ )
+ );
+ if (message.content.layout === "tracking-protections") {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-subtitle"
+ )
+ );
+ }
+ if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) => el === "h2" && args.content === 3
+ )
+ );
+ }
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-content"
+ )
+ );
+ }
+ // Call the click handler to make coverage happy.
+ eventListeners.mouseup();
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ });
+ it("should clear previous messages on 2nd renderMessages()", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ const removeStub = sandbox.stub();
+ fakeElementById.querySelectorAll.onCall(0).returns([]);
+ fakeElementById.querySelectorAll
+ .onCall(1)
+ .returns([{ remove: removeStub }, { remove: removeStub }]);
+
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledTwice(removeStub);
+ });
+ it("should sort based on order field value", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m =>
+ m.template === "whatsnew_panel_message" &&
+ m.content.published_date === 1560969794394
+ );
+
+ messages.forEach(m => (m.content.title = m.order));
+
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ // Select the title elements that are supposed to be set to the same
+ // value as the `order` field of the message
+ const titleEls = fakeRemoteL10n.createElement.args
+ .filter(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-title"
+ )
+ .map(([doc, el, args]) => args.content);
+ assert.deepEqual(titleEls, [1, 2, 3]);
+ });
+ it("should accept string for image attributes", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.id === "WHATS_NEW_70_1"
+ );
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const imageEl = createdCustomElements.find(el => el.tagName === "img");
+ assert.calledOnce(imageEl.setAttribute);
+ assert.calledWithExactly(
+ imageEl.setAttribute,
+ "alt",
+ "Firefox Send Logo"
+ );
+ });
+ it("should set state values as data-attribute", async () => {
+ const message = (await PanelTestProvider.getMessages()).find(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns([message]);
+ instance.state.contentArguments = { foo: "foo", bar: "bar" };
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const [, , args] = fakeRemoteL10n.createElement.args.find(
+ ([doc, el, elArgs]) => elArgs && elArgs.attributes
+ );
+ assert.ok(args);
+ // Currently this.state.contentArguments has 8 different entries
+ assert.lengthOf(Object.keys(args.attributes), 8);
+ assert.equal(
+ args.attributes.searchEngineName,
+ defaultSearchStub.defaultEngine.name
+ );
+ });
+ it("should only render unique dates (no duplicates)", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ const uniqueDates = [
+ ...new Set(messages.map(m => m.content.published_date)),
+ ];
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const dateElements = fakeRemoteL10n.createElement.args.filter(
+ ([doc, el, args]) =>
+ el === "p" && args.classList === "whatsNew-message-date"
+ );
+ assert.lengthOf(dateElements, uniqueDates.length);
+ });
+ it("should listen for panelhidden and remove the toolbar button", async () => {
+ getMessagesStub.returns([]);
+ fakeDocument.getElementById
+ .withArgs("customizationui-widget-panel")
+ .returns(null);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.notCalled(fakeElementById.addEventListener);
+ });
+ it("should attach doCommand cbs that handle user actions", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const messageEl = createdCustomElements.find(
+ el =>
+ el.tagName === "div" && el.classList.includes("whatsNew-message-body")
+ );
+ const anchorEl = createdCustomElements.find(el => el.tagName === "a");
+
+ assert.notCalled(global.SpecialMessageActions.handleAction);
+
+ messageEl.doCommand();
+ anchorEl.doCommand();
+
+ assert.calledTwice(global.SpecialMessageActions.handleAction);
+ });
+ it("should listen for panelhidden and remove the toolbar button", async () => {
+ getMessagesStub.returns([]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(fakeElementById.addEventListener);
+ assert.calledWithExactly(
+ fakeElementById.addEventListener,
+ "popuphidden",
+ sinon.match.func,
+ {
+ once: true,
+ }
+ );
+ const [, cb] = fakeElementById.addEventListener.firstCall.args;
+
+ assert.notCalled(everyWindowStub.unregisterCallback);
+
+ cb();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(
+ everyWindowStub.unregisterCallback,
+ "whats-new-menu-button"
+ );
+ });
+ describe("#IMPRESSION", () => {
+ it("should dispatch a IMPRESSION for messages", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledOnce(fakeSendTelemetry);
+ assert.propertyVal(
+ spy.firstCall.args[2],
+ "id",
+ messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",")
+ );
+ });
+ it("should dispatch a CLICK for clicking a message", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ // Force to render the message
+ fakeElementById.querySelector.returns(null);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns([messages[0]]);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledOnce(fakeSendTelemetry);
+
+ spy.resetHistory();
+
+ // Message click event listener cb
+ eventListeners.mouseup();
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]);
+ });
+ it("should dispatch a IMPRESSION with toolbar_dropdown", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.resolves(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+ const panelPingId = messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(
+ spy,
+ fakeWindow,
+ "IMPRESSION",
+ {
+ id: panelPingId,
+ },
+ {
+ value: {
+ view: "toolbar_dropdown",
+ },
+ }
+ );
+ assert.calledOnce(fakeSendTelemetry);
+ const {
+ args: [dispatchPayload],
+ } = fakeSendTelemetry.lastCall;
+ assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
+ assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
+ assert.deepEqual(dispatchPayload.data.event_context, {
+ view: "toolbar_dropdown",
+ });
+ });
+ it("should dispatch a IMPRESSION with application_menu", async () => {
+ // means panel is triggered as a subview in the application menu
+ fakeElementById.hasAttribute.returns(false);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.resolves(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+ const panelPingId = messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(
+ spy,
+ fakeWindow,
+ "IMPRESSION",
+ {
+ id: panelPingId,
+ },
+ {
+ value: {
+ view: "application_menu",
+ },
+ }
+ );
+ assert.calledOnce(fakeSendTelemetry);
+ const {
+ args: [dispatchPayload],
+ } = fakeSendTelemetry.lastCall;
+ assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
+ assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
+ assert.deepEqual(dispatchPayload.data.event_context, {
+ view: "application_menu",
+ });
+ });
+ });
+ describe("#forceShowMessage", () => {
+ const panelSelector = "PanelUI-whatsNew-message-container";
+ let removeMessagesSpy;
+ let renderMessagesStub;
+ let addEventListenerStub;
+ let messages;
+ let browser;
+ beforeEach(async () => {
+ messages = (await PanelTestProvider.getMessages()).find(
+ m => m.id === "WHATS_NEW_70_1"
+ );
+ removeMessagesSpy = sandbox.spy(instance, "removeMessages");
+ renderMessagesStub = sandbox.spy(instance, "renderMessages");
+ addEventListenerStub = fakeElementById.addEventListener;
+ browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument };
+ fakeElementById.querySelectorAll.returns([fakeElementById]);
+ });
+ it("should call removeMessages when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ it("should call renderMessages when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledOnce(renderMessagesStub);
+ assert.calledWithExactly(
+ renderMessagesStub,
+ fakeWindow,
+ fakeDocument,
+ panelSelector,
+ {
+ force: true,
+ messages: Array.isArray(messages) ? messages : [messages],
+ }
+ );
+ });
+ it("should cleanup after the panel is hidden when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledOnce(addEventListenerStub);
+ assert.calledWithExactly(
+ addEventListenerStub,
+ "popuphidden",
+ sinon.match.func
+ );
+
+ const [, cb] = addEventListenerStub.firstCall.args;
+ // Reset the call count from the first `forceShowMessage` call
+ removeMessagesSpy.resetHistory();
+ cb({ target: { ownerGlobal: fakeWindow } });
+
+ assert.calledOnce(removeMessagesSpy);
+ assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ it("should exit gracefully if called before a browser exists", () => {
+ instance.forceShowMessage(null, messages);
+ assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/asrouter-utils.test.js b/browser/components/asrouter/tests/unit/asrouter-utils.test.js
new file mode 100644
index 0000000000..553c9608ed
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/asrouter-utils.test.js
@@ -0,0 +1,118 @@
+import { ASRouterUtils } from "content-src/asrouter-utils";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("ASRouterUtils", () => {
+ let globals = null;
+ let overrider = null;
+ let sandbox = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = {
+ ASRouterMessage: sandbox.stub().resolves({}),
+ };
+ overrider = new GlobalOverrider();
+ overrider.set(globals);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ overrider.restore();
+ });
+ describe("sendMessage", () => {
+ it("default", () => {
+ ASRouterUtils.sendMessage({ foo: "bar" });
+ assert.calledOnce(globals.ASRouterMessage);
+ assert.calledWith(globals.ASRouterMessage, { foo: "bar" });
+ });
+ it("throws if ASRouterMessage is not defined", () => {
+ overrider.set("ASRouterMessage", undefined);
+ assert.throws(() => ASRouterUtils.sendMessage({ foo: "bar" }));
+ });
+ it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => {
+ assert.doesNotThrow(async () => {
+ let result = await ASRouterUtils.sendMessage({
+ type: "NEWTAB_MESSAGE_REQUEST",
+ data: {},
+ });
+ sandbox.assert.deepEqual(result, {});
+ });
+ });
+ });
+ describe("blockById", () => {
+ it("default", () => {
+ ASRouterUtils.blockById(1, { foo: "bar" });
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { foo: "bar", id: 1 } })
+ );
+ });
+ });
+ describe("modifyMessageJson", () => {
+ it("default", () => {
+ ASRouterUtils.modifyMessageJson({ foo: "bar" });
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { content: { foo: "bar" } } })
+ );
+ });
+ });
+ describe("executeAction", () => {
+ it("default", () => {
+ ASRouterUtils.executeAction({ foo: "bar" });
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { foo: "bar" } })
+ );
+ });
+ });
+ describe("unblockById", () => {
+ it("default", () => {
+ ASRouterUtils.unblockById(2);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { id: 2 } })
+ );
+ });
+ });
+ describe("blockBundle", () => {
+ it("default", () => {
+ ASRouterUtils.blockBundle(2);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { bundle: 2 } })
+ );
+ });
+ });
+ describe("unblockBundle", () => {
+ it("default", () => {
+ ASRouterUtils.unblockBundle(2);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { bundle: 2 } })
+ );
+ });
+ });
+ describe("overrideMessage", () => {
+ it("default", () => {
+ ASRouterUtils.overrideMessage(12);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { id: 12 } })
+ );
+ });
+ });
+ describe("editState", () => {
+ it("default", () => {
+ ASRouterUtils.editState("foo", "bar");
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { foo: "bar" } })
+ );
+ });
+ });
+ describe("sendTelemetry", () => {
+ it("default", () => {
+ ASRouterUtils.sendTelemetry({ foo: "bar" });
+ assert.calledOnce(globals.ASRouterMessage);
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/constants.js b/browser/components/asrouter/tests/unit/constants.js
new file mode 100644
index 0000000000..82b88c47a2
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/constants.js
@@ -0,0 +1,131 @@
+export const CHILD_TO_PARENT_MESSAGE_NAME = "ASRouter:child-to-parent";
+export const PARENT_TO_CHILD_MESSAGE_NAME = "ASRouter:parent-to-child";
+
+export const FAKE_LOCAL_MESSAGES = [
+ {
+ id: "foo",
+ template: "milestone_message",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "foo1",
+ template: "fancy_template",
+ bundled: 2,
+ order: 1,
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ {
+ id: "foo2",
+ template: "fancy_template",
+ bundled: 2,
+ order: 2,
+ content: { title: "Foo2", body: "Foo123-2" },
+ },
+ {
+ id: "bar",
+ template: "fancy_template",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ { id: "baz", content: { title: "Foo", body: "Foo123" } },
+ {
+ id: "newsletter",
+ template: "fancy_template",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "fxa",
+ template: "fancy_template",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "belowsearch",
+ template: "fancy_template",
+ content: { text: "Foo" },
+ },
+];
+export const FAKE_LOCAL_PROVIDER = {
+ id: "onboarding",
+ type: "local",
+ localProvider: "FAKE_LOCAL_PROVIDER",
+ enabled: true,
+ cohort: 0,
+};
+export const FAKE_LOCAL_PROVIDERS = {
+ FAKE_LOCAL_PROVIDER: {
+ getMessages: () => Promise.resolve(FAKE_LOCAL_MESSAGES),
+ },
+};
+
+export const FAKE_REMOTE_MESSAGES = [
+ {
+ id: "qux",
+ template: "fancy_template",
+ content: { title: "Qux", body: "hello world" },
+ },
+];
+export const FAKE_REMOTE_PROVIDER = {
+ id: "remotey",
+ type: "remote",
+ url: "http://fake.com/endpoint",
+ enabled: true,
+};
+
+export const FAKE_REMOTE_SETTINGS_PROVIDER = {
+ id: "remotey-settingsy",
+ type: "remote-settings",
+ collection: "collectionname",
+ enabled: true,
+};
+
+const notificationText = new String("Fake notification text"); // eslint-disable-line
+notificationText.attributes = { tooltiptext: "Fake tooltip text" };
+
+export const FAKE_RECOMMENDATION = {
+ id: "fake_id",
+ template: "cfr_doorhanger",
+ content: {
+ category: "cfrDummy",
+ bucket_id: "fake_bucket_id",
+ notification_text: notificationText,
+ info_icon: {
+ label: "Fake Info Icon Label",
+ sumo_path: "a_help_path_fragment",
+ },
+ heading_text: "Fake Heading Text",
+ icon_class: "Fake Icon class",
+ addon: {
+ title: "Fake Addon Title",
+ author: "Fake Addon Author",
+ icon: "a_path_to_some_icon",
+ rating: "4.2",
+ users: "1234",
+ amo_url: "a_path_to_amo",
+ },
+ descriptionDetails: {
+ steps: [{ string_id: "cfr-features-step1" }],
+ },
+ text: "Here is the recommendation text body",
+ buttons: {
+ primary: {
+ label: { string_id: "primary_button_id" },
+ action: {
+ id: "primary_action",
+ data: {},
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "secondary_button_id" },
+ action: { id: "secondary_action" },
+ },
+ {
+ label: { string_id: "secondary_button_id_2" },
+ },
+ {
+ label: { string_id: "secondary_button_id_3" },
+ action: { id: "secondary_action" },
+ },
+ ],
+ },
+ },
+};
diff --git a/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx
new file mode 100644
index 0000000000..46d5704107
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx
@@ -0,0 +1,262 @@
+import { ASRouterAdminInner } from "content-src/components/ASRouterAdmin/ASRouterAdmin";
+import { ASRouterUtils } from "content-src/asrouter-utils";
+import { GlobalOverrider } from "test/unit/utils";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("ASRouterAdmin", () => {
+ let globalOverrider;
+ let sandbox;
+ let wrapper;
+ let globals;
+ let FAKE_PROVIDER_PREF = [
+ {
+ enabled: true,
+ id: "local_testing",
+ localProvider: "TestProvider",
+ type: "local",
+ },
+ ];
+ let FAKE_PROVIDER = [
+ {
+ enabled: true,
+ id: "local_testing",
+ localProvider: "TestProvider",
+ messages: [],
+ type: "local",
+ },
+ ];
+ beforeEach(() => {
+ globalOverrider = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns("foo");
+ globals = {
+ ASRouterMessage: sandbox.stub().resolves(),
+ ASRouterAddParentListener: sandbox.stub(),
+ ASRouterRemoveParentListener: sandbox.stub(),
+ };
+ globalOverrider.set(globals);
+ wrapper = shallow(<ASRouterAdminInner location={{ routes: [""] }} />);
+ wrapper.setState({ devtoolsEnabled: true });
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globalOverrider.restore();
+ });
+ it("should render ASRouterAdmin component", () => {
+ assert.ok(wrapper.exists());
+ });
+ it("should send ADMIN_CONNECT_STATE on mount", () => {
+ assert.calledOnce(globals.ASRouterMessage);
+ assert.calledWith(globals.ASRouterMessage, {
+ type: "ADMIN_CONNECT_STATE",
+ data: { endpoint: "foo" },
+ });
+ });
+ describe("#getSection", () => {
+ it("should render a message provider section by default", () => {
+ assert.equal(wrapper.find("h2").at(1).text(), "Messages");
+ });
+ it("should render a targeting section for targeting route", () => {
+ wrapper = shallow(
+ <ASRouterAdminInner location={{ routes: ["targeting"] }} />
+ );
+ wrapper.setState({ devtoolsEnabled: true });
+ assert.equal(wrapper.find("h2").at(0).text(), "Targeting Utilities");
+ });
+ it("should render two error messages", () => {
+ wrapper = shallow(
+ <ASRouterAdminInner location={{ routes: ["errors"] }} Sections={[]} />
+ );
+ wrapper.setState({ devtoolsEnabled: true });
+ const firstError = {
+ timestamp: Date.now() + 100,
+ error: { message: "first" },
+ };
+ const secondError = {
+ timestamp: Date.now(),
+ error: { message: "second" },
+ };
+ wrapper.setState({
+ providers: [{ id: "foo", errors: [firstError, secondError] }],
+ });
+
+ assert.equal(
+ wrapper.find("tbody tr").at(0).find("td").at(0).text(),
+ "foo"
+ );
+ assert.lengthOf(wrapper.find("tbody tr"), 2);
+ assert.equal(
+ wrapper.find("tbody tr").at(0).find("td").at(1).text(),
+ secondError.error.message
+ );
+ });
+ });
+ describe("#render", () => {
+ beforeEach(() => {
+ wrapper.setState({
+ providerPrefs: [],
+ providers: [],
+ userPrefs: {},
+ });
+ });
+ describe("#renderProviders", () => {
+ it("should render the provider", () => {
+ wrapper.setState({
+ providerPrefs: FAKE_PROVIDER_PREF,
+ providers: FAKE_PROVIDER,
+ });
+
+ // Header + 1 item
+ assert.lengthOf(wrapper.find(".message-item"), 2);
+ });
+ });
+ describe("#renderMessages", () => {
+ beforeEach(() => {
+ sandbox.stub(ASRouterUtils, "blockById").resolves();
+ sandbox.stub(ASRouterUtils, "unblockById").resolves();
+ sandbox.stub(ASRouterUtils, "overrideMessage").resolves({ foo: "bar" });
+ sandbox.stub(ASRouterUtils, "sendMessage").resolves();
+ wrapper.setState({
+ messageFilter: "all",
+ messageBlockList: [],
+ messageImpressions: { foo: 2 },
+ groups: [{ id: "messageProvider", enabled: true }],
+ providers: [{ id: "messageProvider", enabled: true }],
+ });
+ });
+ it("should render a message when no filtering is applied", () => {
+ wrapper.setState({
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+ wrapper.find(".message-item button.primary").simulate("click");
+ assert.calledOnce(ASRouterUtils.blockById);
+ assert.calledWith(ASRouterUtils.blockById, "foo");
+ });
+ it("should render a blocked message", () => {
+ wrapper.setState({
+ messages: [
+ {
+ id: "foo",
+ groups: ["messageProvider"],
+ provider: "messageProvider",
+ },
+ ],
+ messageBlockList: ["foo"],
+ });
+ assert.lengthOf(wrapper.find(".message-item.blocked"), 1);
+ wrapper.find(".message-item.blocked button").simulate("click");
+ assert.calledOnce(ASRouterUtils.unblockById);
+ assert.calledWith(ASRouterUtils.unblockById, "foo");
+ });
+ it("should render a message if provider matches filter", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+ });
+ it("should override with the selected message", async () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+ wrapper.find(".message-item button.show").simulate("click");
+ assert.calledOnce(ASRouterUtils.overrideMessage);
+ assert.calledWith(ASRouterUtils.overrideMessage, "foo");
+ await ASRouterUtils.overrideMessage();
+ assert.equal(wrapper.state().foo, "bar");
+ });
+ it("should hide message if provider filter changes", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+
+ wrapper.find("select").simulate("change", { target: { value: "bar" } });
+
+ assert.lengthOf(wrapper.find(".message-id"), 0);
+ });
+ it("should not display Reset All button if provider filter value is set to all or test providers", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".messages-reset"), 1);
+ wrapper.find("select").simulate("change", { target: { value: "all" } });
+
+ assert.lengthOf(wrapper.find(".messages-reset"), 0);
+
+ wrapper
+ .find("select")
+ .simulate("change", { target: { value: "test_local_testing" } });
+ assert.lengthOf(wrapper.find(".messages-reset"), 0);
+ });
+ it("should trigger disable and enable provider on Reset All button click", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ providerPrefs: [
+ {
+ id: "messageProvider",
+ },
+ ],
+ });
+ wrapper.find(".messages-reset").simulate("click");
+ assert.calledTwice(ASRouterUtils.sendMessage);
+ assert.calledWith(ASRouterUtils.sendMessage, {
+ type: "DISABLE_PROVIDER",
+ data: "messageProvider",
+ });
+ assert.calledWith(ASRouterUtils.sendMessage, {
+ type: "ENABLE_PROVIDER",
+ data: "messageProvider",
+ });
+ });
+ });
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx
new file mode 100644
index 0000000000..9d9984ee85
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx
@@ -0,0 +1,112 @@
+import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs";
+import CFRDoorhangerSchema from "content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json";
+import CFRChicletSchema from "content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json";
+import InfoBarSchema from "content-src/templates/CFR/templates/InfoBar.schema.json";
+
+const SCHEMAS = {
+ cfr_urlbar_chiclet: CFRChicletSchema,
+ cfr_doorhanger: CFRDoorhangerSchema,
+ milestone_message: CFRDoorhangerSchema,
+ infobar: InfoBarSchema,
+};
+
+const DEFAULT_CONTENT = {
+ layout: "addon_recommendation",
+ category: "dummyCategory",
+ bucket_id: "some_bucket_id",
+ notification_text: "Recommendation",
+ heading_text: "Recommended Extension",
+ info_icon: {
+ label: { attributes: { tooltiptext: "Why am I seeing this" } },
+ sumo_path: "extensionrecommendations",
+ },
+ addon: {
+ id: "1234",
+ title: "Addon name",
+ icon: "https://mozilla.org/icon",
+ author: "Author name",
+ amo_url: "https://example.com",
+ },
+ text: "Description of addon",
+ buttons: {
+ primary: {
+ label: {
+ value: "Add Now",
+ attributes: { accesskey: "A" },
+ },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com" },
+ },
+ },
+ secondary: [
+ {
+ label: {
+ value: "Not Now",
+ attributes: { accesskey: "N" },
+ },
+ action: { type: "CANCEL" },
+ },
+ ],
+ },
+};
+
+const L10N_CONTENT = {
+ layout: "addon_recommendation",
+ category: "dummyL10NCategory",
+ bucket_id: "some_bucket_id",
+ notification_text: { string_id: "notification_text_id" },
+ heading_text: { string_id: "heading_text_id" },
+ info_icon: {
+ label: { string_id: "why_seeing_this" },
+ sumo_path: "extensionrecommendations",
+ },
+ addon: {
+ id: "1234",
+ title: "Addon name",
+ icon: "https://mozilla.org/icon",
+ author: "Author name",
+ amo_url: "https://example.com",
+ },
+ text: { string_id: "text_id" },
+ buttons: {
+ primary: {
+ label: { string_id: "btn_ok_id" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com" },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "btn_cancel_id" },
+ action: { type: "CANCEL" },
+ },
+ ],
+ },
+};
+
+describe("ExtensionDoorhanger", () => {
+ it("should validate DEFAULT_CONTENT", async () => {
+ const messages = await CFRMessageProvider.getMessages();
+ let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3");
+ assert.ok(doorhangerMessage, "Message found");
+ assert.jsonSchema(
+ { ...doorhangerMessage, content: DEFAULT_CONTENT },
+ CFRDoorhangerSchema
+ );
+ });
+ it("should validate L10N_CONTENT", async () => {
+ const messages = await CFRMessageProvider.getMessages();
+ let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3");
+ assert.ok(doorhangerMessage, "Message found");
+ assert.jsonSchema(
+ { ...doorhangerMessage, content: L10N_CONTENT },
+ CFRDoorhangerSchema
+ );
+ });
+ it("should validate all messages from CFRMessageProvider", async () => {
+ const messages = await CFRMessageProvider.getMessages();
+ messages.forEach(msg => assert.jsonSchema(msg, SCHEMAS[msg.template]));
+ });
+});
diff --git a/browser/components/asrouter/tests/unit/unit-entry.js b/browser/components/asrouter/tests/unit/unit-entry.js
new file mode 100644
index 0000000000..b8b799e051
--- /dev/null
+++ b/browser/components/asrouter/tests/unit/unit-entry.js
@@ -0,0 +1,727 @@
+import {
+ EventEmitter,
+ FakePrefs,
+ FakensIPrefService,
+ GlobalOverrider,
+ FakeConsoleAPI,
+ FakeLogger,
+} from "newtab/test/unit/utils";
+import Adapter from "enzyme-adapter-react-16";
+import { chaiAssertions } from "newtab/test/schemas/pings";
+import chaiJsonSchema from "chai-json-schema";
+import enzyme from "enzyme";
+import FxMSCommonSchema from "../../content-src/schemas/FxMSCommon.schema.json";
+import {
+ MESSAGE_TYPE_LIST,
+ MESSAGE_TYPE_HASH,
+} from "modules/ActorConstants.sys.mjs";
+
+enzyme.configure({ adapter: new Adapter() });
+
+// Cause React warnings to make tests that trigger them fail
+const origConsoleError = console.error;
+console.error = function (msg, ...args) {
+ origConsoleError.apply(console, [msg, ...args]);
+
+ if (
+ /(Invalid prop|Failed prop type|Check the render method|React Intl)/.test(
+ msg
+ )
+ ) {
+ throw new Error(msg);
+ }
+};
+
+const req = require.context(".", true, /\.test\.jsx?$/);
+const files = req.keys();
+
+// This exposes sinon assertions to chai.assert
+sinon.assert.expose(assert, { prefix: "" });
+
+chai.use(chaiAssertions);
+chai.use(chaiJsonSchema);
+chai.tv4.addSchema("file:///FxMSCommon.schema.json", FxMSCommonSchema);
+
+const overrider = new GlobalOverrider();
+
+const RemoteSettings = name => ({
+ get: () => {
+ if (name === "attachment") {
+ return Promise.resolve([{ attachment: {} }]);
+ }
+ return Promise.resolve([]);
+ },
+ on: () => {},
+ off: () => {},
+});
+RemoteSettings.pollChanges = () => {};
+
+class JSWindowActorParent {
+ sendAsyncMessage(name, data) {
+ return { name, data };
+ }
+}
+
+class JSWindowActorChild {
+ sendAsyncMessage(name, data) {
+ return { name, data };
+ }
+
+ sendQuery(name, data) {
+ return Promise.resolve({ name, data });
+ }
+
+ get contentWindow() {
+ return {
+ Promise,
+ };
+ }
+}
+
+// Detect plain object passed to lazy getter APIs, and set its prototype to
+// global object, and return the global object for further modification.
+// Returns the object if it's not plain object.
+//
+// This is a workaround to make the existing testharness and testcase keep
+// working even after lazy getters are moved to plain `lazy` object.
+const cachedPlainObject = new Set();
+function updateGlobalOrObject(object) {
+ // Given this function modifies the prototype, and the following
+ // condition doesn't meet on the second call, cache the result.
+ if (cachedPlainObject.has(object)) {
+ return global;
+ }
+
+ if (Object.getPrototypeOf(object).constructor.name !== "Object") {
+ return object;
+ }
+
+ cachedPlainObject.add(object);
+ Object.setPrototypeOf(object, global);
+ return global;
+}
+
+const TEST_GLOBAL = {
+ JSWindowActorParent,
+ JSWindowActorChild,
+ AboutReaderParent: {
+ addMessageListener: (messageName, listener) => {},
+ removeMessageListener: (messageName, listener) => {},
+ },
+ AboutWelcomeTelemetry: class {
+ submitGleanPingForPing() {}
+ },
+ AddonManager: {
+ getActiveAddons() {
+ return Promise.resolve({ addons: [], fullData: false });
+ },
+ },
+ AppConstants: {
+ MOZILLA_OFFICIAL: true,
+ MOZ_APP_VERSION: "69.0a1",
+ isChinaRepack() {
+ return false;
+ },
+ isPlatformAndVersionAtMost() {
+ return false;
+ },
+ platform: "win",
+ },
+ ASRouterPreferences: {
+ console: new FakeConsoleAPI({
+ maxLogLevel: "off", // set this to "debug" or "all" to get more ASRouter logging in tests
+ prefix: "ASRouter",
+ }),
+ },
+ AWScreenUtils: {
+ evaluateTargetingAndRemoveScreens() {
+ return true;
+ },
+ async removeScreens() {
+ return true;
+ },
+ evaluateScreenTargeting() {
+ return true;
+ },
+ },
+ BrowserUtils: {
+ sendToDeviceEmailsSupported() {
+ return true;
+ },
+ },
+ UpdateUtils: { getUpdateChannel() {} },
+ BasePromiseWorker: class {
+ constructor() {
+ this.ExceptionHandlers = [];
+ }
+ post() {}
+ },
+ browserSearchRegion: "US",
+ BrowserWindowTracker: { getTopWindow() {} },
+ ChromeUtils: {
+ defineLazyGetter(object, name, f) {
+ updateGlobalOrObject(object)[name] = f();
+ },
+ defineModuleGetter: updateGlobalOrObject,
+ defineESModuleGetters: updateGlobalOrObject,
+ generateQI() {
+ return {};
+ },
+ import() {
+ return global;
+ },
+ importESModule() {
+ return global;
+ },
+ },
+ ClientEnvironment: {
+ get userId() {
+ return "foo123";
+ },
+ },
+ Components: {
+ Constructor(classId) {
+ switch (classId) {
+ case "@mozilla.org/referrer-info;1":
+ return function (referrerPolicy, sendReferrer, originalReferrer) {
+ this.referrerPolicy = referrerPolicy;
+ this.sendReferrer = sendReferrer;
+ this.originalReferrer = originalReferrer;
+ };
+ }
+ return function () {};
+ },
+ isSuccessCode: () => true,
+ },
+ ConsoleAPI: FakeConsoleAPI,
+ // NB: These are functions/constructors
+ // eslint-disable-next-line object-shorthand
+ ContentSearchUIController: function () {},
+ // eslint-disable-next-line object-shorthand
+ ContentSearchHandoffUIController: function () {},
+ Cc: {
+ "@mozilla.org/browser/nav-bookmarks-service;1": {
+ addObserver() {},
+ getService() {
+ return this;
+ },
+ removeObserver() {},
+ SOURCES: {},
+ TYPE_BOOKMARK: {},
+ },
+ "@mozilla.org/browser/nav-history-service;1": {
+ addObserver() {},
+ executeQuery() {},
+ getNewQuery() {},
+ getNewQueryOptions() {},
+ getService() {
+ return this;
+ },
+ insert() {},
+ markPageAsTyped() {},
+ removeObserver() {},
+ },
+ "@mozilla.org/io/string-input-stream;1": {
+ createInstance() {
+ return {};
+ },
+ },
+ "@mozilla.org/security/hash;1": {
+ createInstance() {
+ return {
+ init() {},
+ updateFromStream() {},
+ finish() {
+ return "0";
+ },
+ };
+ },
+ },
+ "@mozilla.org/updates/update-checker;1": { createInstance() {} },
+ "@mozilla.org/widget/useridleservice;1": {
+ getService() {
+ return {
+ idleTime: 0,
+ addIdleObserver() {},
+ removeIdleObserver() {},
+ };
+ },
+ },
+ "@mozilla.org/streamConverters;1": {
+ getService() {
+ return this;
+ },
+ },
+ "@mozilla.org/network/stream-loader;1": {
+ createInstance() {
+ return {};
+ },
+ },
+ },
+ Ci: {
+ nsICryptoHash: {},
+ nsIReferrerInfo: { UNSAFE_URL: 5 },
+ nsITimer: { TYPE_ONE_SHOT: 1 },
+ nsIWebProgressListener: { LOCATION_CHANGE_SAME_DOCUMENT: 1 },
+ nsIDOMWindow: Object,
+ nsITrackingDBService: {
+ TRACKERS_ID: 1,
+ TRACKING_COOKIES_ID: 2,
+ CRYPTOMINERS_ID: 3,
+ FINGERPRINTERS_ID: 4,
+ SOCIAL_ID: 5,
+ },
+ nsICookieBannerService: {
+ MODE_DISABLED: 0,
+ MODE_REJECT: 1,
+ MODE_REJECT_OR_ACCEPT: 2,
+ MODE_UNSET: 3,
+ },
+ },
+ Cu: {
+ importGlobalProperties() {},
+ now: () => window.performance.now(),
+ cloneInto: o => JSON.parse(JSON.stringify(o)),
+ },
+ console: {
+ ...console,
+ error() {},
+ },
+ dump() {},
+ EveryWindow: {
+ registerCallback: (id, init, uninit) => {},
+ unregisterCallback: id => {},
+ },
+ setTimeout: window.setTimeout.bind(window),
+ clearTimeout: window.clearTimeout.bind(window),
+ fetch() {},
+ // eslint-disable-next-line object-shorthand
+ Image: function () {}, // NB: This is a function/constructor
+ IOUtils: {
+ writeJSON() {
+ return Promise.resolve(0);
+ },
+ readJSON() {
+ return Promise.resolve({});
+ },
+ read() {
+ return Promise.resolve(new Uint8Array());
+ },
+ makeDirectory() {
+ return Promise.resolve(0);
+ },
+ write() {
+ return Promise.resolve(0);
+ },
+ exists() {
+ return Promise.resolve(0);
+ },
+ remove() {
+ return Promise.resolve(0);
+ },
+ stat() {
+ return Promise.resolve(0);
+ },
+ },
+ NewTabUtils: {
+ activityStreamProvider: {
+ getTopFrecentSites: () => [],
+ executePlacesQuery: async (sql, options) => ({ sql, options }),
+ },
+ },
+ OS: {
+ File: {
+ writeAtomic() {},
+ makeDir() {},
+ stat() {},
+ Error: {},
+ read() {},
+ exists() {},
+ remove() {},
+ removeEmptyDir() {},
+ },
+ Path: {
+ join() {
+ return "/";
+ },
+ },
+ Constants: {
+ Path: {
+ localProfileDir: "/",
+ },
+ },
+ },
+ PathUtils: {
+ join(...parts) {
+ return parts[parts.length - 1];
+ },
+ joinRelative(...parts) {
+ return parts[parts.length - 1];
+ },
+ getProfileDir() {
+ return Promise.resolve("/");
+ },
+ getLocalProfileDir() {
+ return Promise.resolve("/");
+ },
+ },
+ PlacesUtils: {
+ get bookmarks() {
+ return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-bookmarks-service;1"];
+ },
+ get history() {
+ return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-history-service;1"];
+ },
+ observers: {
+ addListener() {},
+ removeListener() {},
+ },
+ },
+ Preferences: FakePrefs,
+ PrivateBrowsingUtils: {
+ isBrowserPrivate: () => false,
+ isWindowPrivate: () => false,
+ permanentPrivateBrowsing: false,
+ },
+ DownloadsViewUI: {
+ getDisplayName: () => "filename.ext",
+ getSizeWithUnits: () => "1.5 MB",
+ },
+ FileUtils: {
+ // eslint-disable-next-line object-shorthand
+ File: function () {}, // NB: This is a function/constructor
+ },
+ Region: {
+ home: "US",
+ REGION_TOPIC: "browser-region-updated",
+ },
+ Services: {
+ dirsvc: {
+ get: () => ({ parent: { parent: { path: "appPath" } } }),
+ },
+ env: {
+ set: () => undefined,
+ },
+ locale: {
+ get appLocaleAsBCP47() {
+ return "en-US";
+ },
+ negotiateLanguages() {},
+ },
+ urlFormatter: { formatURL: str => str, formatURLPref: str => str },
+ mm: {
+ addMessageListener: (msg, cb) => this.receiveMessage(),
+ removeMessageListener() {},
+ },
+ obs: {
+ addObserver() {},
+ removeObserver() {},
+ notifyObservers() {},
+ },
+ telemetry: {
+ setEventRecordingEnabled: () => {},
+ recordEvent: eventDetails => {},
+ scalarSet: () => {},
+ keyedScalarAdd: () => {},
+ },
+ uuid: {
+ generateUUID() {
+ return "{foo-123-foo}";
+ },
+ },
+ console: { logStringMessage: () => {} },
+ prefs: new FakensIPrefService(),
+ tm: {
+ dispatchToMainThread: cb => cb(),
+ idleDispatchToMainThread: cb => cb(),
+ },
+ eTLD: {
+ getBaseDomain({ spec }) {
+ return spec.match(/\/([^/]+)/)[1];
+ },
+ getBaseDomainFromHost(host) {
+ return host.match(/.*?(\w+\.\w+)$/)[1];
+ },
+ getPublicSuffix() {},
+ },
+ io: {
+ newURI: spec => ({
+ mutate: () => ({
+ setRef: ref => ({
+ finalize: () => ({
+ ref,
+ spec,
+ }),
+ }),
+ }),
+ spec,
+ }),
+ },
+ search: {
+ init() {
+ return Promise.resolve();
+ },
+ getVisibleEngines: () =>
+ Promise.resolve([{ identifier: "google" }, { identifier: "bing" }]),
+ defaultEngine: {
+ identifier: "google",
+ searchForm:
+ "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b",
+ aliases: ["@google"],
+ },
+ defaultPrivateEngine: {
+ identifier: "bing",
+ searchForm: "https://www.bing.com",
+ aliases: ["@bing"],
+ },
+ getEngineByAlias: async () => null,
+ },
+ scriptSecurityManager: {
+ createNullPrincipal() {},
+ getSystemPrincipal() {},
+ },
+ wm: {
+ getMostRecentWindow: () => window,
+ getMostRecentBrowserWindow: () => window,
+ getEnumerator: () => [],
+ },
+ ww: { registerNotification() {}, unregisterNotification() {} },
+ appinfo: { appBuildID: "20180710100040", version: "69.0a1" },
+ scriptloader: { loadSubScript: () => {} },
+ startup: {
+ getStartupInfo() {
+ return {
+ process: {
+ getTime() {
+ return 1588010448000;
+ },
+ },
+ };
+ },
+ },
+ },
+ XPCOMUtils: {
+ defineLazyGlobalGetters: updateGlobalOrObject,
+ defineLazyModuleGetters: updateGlobalOrObject,
+ defineLazyServiceGetter: updateGlobalOrObject,
+ defineLazyServiceGetters: updateGlobalOrObject,
+ defineLazyPreferenceGetter(object, name) {
+ updateGlobalOrObject(object)[name] = "";
+ },
+ generateQI() {
+ return {};
+ },
+ },
+ EventEmitter,
+ ShellService: {
+ doesAppNeedPin: () => false,
+ isDefaultBrowser: () => true,
+ },
+ FilterExpressions: {
+ eval() {
+ return Promise.resolve(false);
+ },
+ },
+ RemoteSettings,
+ Localization: class {
+ async formatMessages(stringsIds) {
+ return Promise.resolve(
+ stringsIds.map(({ id, args }) => ({ value: { string_id: id, args } }))
+ );
+ }
+ async formatValue(stringId) {
+ return Promise.resolve(stringId);
+ }
+ },
+ FxAccountsConfig: {
+ promiseConnectAccountURI(id) {
+ return Promise.resolve(id);
+ },
+ },
+ FX_MONITOR_OAUTH_CLIENT_ID: "fake_client_id",
+ ExperimentAPI: {
+ getExperiment() {},
+ getExperimentMetaData() {},
+ getRolloutMetaData() {},
+ },
+ NimbusFeatures: {
+ glean: {
+ getVariable() {},
+ },
+ newtab: {
+ getVariable() {},
+ getAllVariables() {},
+ onUpdate() {},
+ offUpdate() {},
+ },
+ pocketNewtab: {
+ getVariable() {},
+ getAllVariables() {},
+ onUpdate() {},
+ offUpdate() {},
+ },
+ cookieBannerHandling: {
+ getVariable() {},
+ },
+ },
+ TelemetryEnvironment: {
+ setExperimentActive() {},
+ currentEnvironment: {
+ profile: {
+ creationDate: 16587,
+ },
+ settings: {},
+ },
+ },
+ TelemetryStopwatch: {
+ start: () => {},
+ finish: () => {},
+ },
+ Sampling: {
+ ratioSample(seed, ratios) {
+ return Promise.resolve(0);
+ },
+ },
+ BrowserHandler: {
+ get kiosk() {
+ return false;
+ },
+ },
+ TelemetrySession: {
+ getMetadata(reason) {
+ return {
+ reason,
+ sessionId: "fake_session_id",
+ };
+ },
+ },
+ PageThumbs: {
+ addExpirationFilter() {},
+ removeExpirationFilter() {},
+ },
+ Logger: FakeLogger,
+ getFxAccountsSingleton() {},
+ AboutNewTab: {},
+ Glean: {
+ newtab: {
+ opened: {
+ record() {},
+ },
+ closed: {
+ record() {},
+ },
+ locale: {
+ set() {},
+ },
+ newtabCategory: {
+ set() {},
+ },
+ homepageCategory: {
+ set() {},
+ },
+ blockedSponsors: {
+ set() {},
+ },
+ sovAllocation: {
+ set() {},
+ },
+ },
+ newtabSearch: {
+ enabled: {
+ set() {},
+ },
+ },
+ pocket: {
+ enabled: {
+ set() {},
+ },
+ impression: {
+ record() {},
+ },
+ isSignedIn: {
+ set() {},
+ },
+ sponsoredStoriesEnabled: {
+ set() {},
+ },
+ click: {
+ record() {},
+ },
+ save: {
+ record() {},
+ },
+ topicClick: {
+ record() {},
+ },
+ },
+ topsites: {
+ enabled: {
+ set() {},
+ },
+ sponsoredEnabled: {
+ set() {},
+ },
+ impression: {
+ record() {},
+ },
+ click: {
+ record() {},
+ },
+ rows: {
+ set() {},
+ },
+ showPrivacyClick: {
+ record() {},
+ },
+ dismiss: {
+ record() {},
+ },
+ prefChanged: {
+ record() {},
+ },
+ },
+ topSites: {
+ pingType: {
+ set() {},
+ },
+ position: {
+ set() {},
+ },
+ source: {
+ set() {},
+ },
+ tileId: {
+ set() {},
+ },
+ reportingUrl: {
+ set() {},
+ },
+ advertiser: {
+ set() {},
+ },
+ contextId: {
+ set() {},
+ },
+ },
+ },
+ GleanPings: {
+ newtab: {
+ submit() {},
+ },
+ topSites: {
+ submit() {},
+ },
+ },
+ Utils: {
+ SERVER_URL: "bogus://foo",
+ },
+
+ MESSAGE_TYPE_LIST,
+ MESSAGE_TYPE_HASH,
+};
+overrider.set(TEST_GLOBAL);
+
+describe("asrouter", () => {
+ after(() => overrider.restore());
+ files.forEach(file => req(file));
+});
diff --git a/browser/components/asrouter/tests/xpcshell/head.js b/browser/components/asrouter/tests/xpcshell/head.js
new file mode 100644
index 0000000000..0c6cec1ac8
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/head.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
+});
+
+function assertValidates(validator, obj, msg) {
+ const result = validator.validate(obj);
+ Assert.ok(
+ result.valid && result.errors.length === 0,
+ `${msg} - errors = ${JSON.stringify(result.errors, undefined, 2)}`
+ );
+}
+
+async function fetchSchema(uri) {
+ try {
+ dump(`URI: ${uri}\n`);
+ return fetch(uri, { credentials: "omit" }).then(rsp => rsp.json());
+ } catch (e) {
+ throw new Error(`Could not fetch ${uri}`);
+ }
+}
+
+async function schemaValidatorFor(uri, { common = false } = {}) {
+ const schema = await fetchSchema(uri);
+ const validator = new lazy.JsonSchema.Validator(schema);
+
+ if (common) {
+ const commonSchema = await fetchSchema(
+ "resource://testing-common/FxMSCommon.schema.json"
+ );
+ validator.addSchema(commonSchema);
+ }
+
+ return validator;
+}
+
+async function makeValidators() {
+ const experimentValidator = await schemaValidatorFor(
+ "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json"
+ );
+
+ const messageValidators = {
+ cfr_doorhanger: await schemaValidatorFor(
+ "resource://testing-common/ExtensionDoorhanger.schema.json",
+ { common: true }
+ ),
+ cfr_urlbar_chiclet: await schemaValidatorFor(
+ "resource://testing-common/CFRUrlbarChiclet.schema.json",
+ { common: true }
+ ),
+ infobar: await schemaValidatorFor(
+ "resource://testing-common/InfoBar.schema.json",
+ { common: true }
+ ),
+ pb_newtab: await schemaValidatorFor(
+ "resource://testing-common/NewtabPromoMessage.schema.json",
+ { common: true }
+ ),
+ spotlight: await schemaValidatorFor(
+ "resource://testing-common/Spotlight.schema.json",
+ { common: true }
+ ),
+ toast_notification: await schemaValidatorFor(
+ "resource://testing-common/ToastNotification.schema.json",
+ { common: true }
+ ),
+ toolbar_badge: await schemaValidatorFor(
+ "resource://testing-common/ToolbarBadgeMessage.schema.json",
+ { common: true }
+ ),
+ update_action: await schemaValidatorFor(
+ "resource://testing-common/UpdateAction.schema.json",
+ { common: true }
+ ),
+ whatsnew_panel_message: await schemaValidatorFor(
+ "resource://testing-common/WhatsNewMessage.schema.json",
+ { common: true }
+ ),
+ feature_callout: await schemaValidatorFor(
+ // For now, Feature Callout and Spotlight share a common schema
+ "resource://testing-common/Spotlight.schema.json",
+ { common: true }
+ ),
+ };
+
+ messageValidators.milestone_message = messageValidators.cfr_doorhanger;
+
+ return { experimentValidator, messageValidators };
+}
diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js
new file mode 100644
index 0000000000..a37cb6c793
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { ASRouterTargeting } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouterTargeting.sys.mjs"
+);
+const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+);
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+add_task(async function check_attribution_data() {
+ // Some setup to fake the correct attribution data
+ const campaign = "non-fx-button";
+ const source = "addons.mozilla.org";
+ const attrStr = `campaign%3D${campaign}%26source%3D${source}`;
+ await MacAttribution.setAttributionString(attrStr);
+ AttributionCode._clearCache();
+ await AttributionCode.getAttrDataAsync();
+
+ const { campaign: attributionCampain, source: attributionSource } =
+ ASRouterTargeting.Environment.attributionData;
+ equal(
+ attributionCampain,
+ campaign,
+ "should get the correct campaign out of attributionData"
+ );
+ equal(
+ attributionSource,
+ source,
+ "should get the correct source out of attributionData"
+ );
+
+ const messages = [
+ {
+ id: "foo1",
+ targeting:
+ "attributionData.campaign == 'back_to_school' && attributionData.source == 'addons.mozilla.org'",
+ },
+ {
+ id: "foo2",
+ targeting:
+ "attributionData.campaign == 'non-fx-button' && attributionData.source == 'addons.mozilla.org'",
+ },
+ ];
+
+ equal(
+ await ASRouterTargeting.findMatchingMessage({ messages }),
+ messages[1],
+ "should select the message with the correct campaign and source"
+ );
+ AttributionCode._clearCache();
+});
+
+add_task(async function check_enterprise_targeting() {
+ const messages = [
+ {
+ id: "foo1",
+ targeting: "hasActiveEnterprisePolicies",
+ },
+ {
+ id: "foo2",
+ targeting: "!hasActiveEnterprisePolicies",
+ },
+ ];
+
+ equal(
+ await ASRouterTargeting.findMatchingMessage({ messages }),
+ messages[1],
+ "should select the message for policies turned off"
+ );
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ DisableFirefoxStudies: {
+ Value: true,
+ },
+ },
+ });
+
+ equal(
+ await ASRouterTargeting.findMatchingMessage({ messages }),
+ messages[0],
+ "should select the message for policies turned on"
+ );
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js
new file mode 100644
index 0000000000..74171ba1b9
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js
@@ -0,0 +1,172 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { ASRouterTargeting } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouterTargeting.sys.mjs"
+);
+
+add_task(async function should_ignore_rejections() {
+ let target = {
+ get foo() {
+ return new Promise(resolve => resolve(1));
+ },
+
+ get bar() {
+ return new Promise((resolve, reject) => reject(new Error("unspecified")));
+ },
+ };
+
+ let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({
+ targets: [target],
+ });
+ Assert.deepEqual(snapshot, { environment: { foo: 1 }, version: 1 });
+});
+
+add_task(async function nested_objects() {
+ const target = {
+ get foo() {
+ return Promise.resolve("foo");
+ },
+ get bar() {
+ return Promise.reject(new Error("bar"));
+ },
+ baz: {
+ get qux() {
+ return Promise.resolve("qux");
+ },
+ get quux() {
+ return Promise.reject(new Error("quux"));
+ },
+ get corge() {
+ return {
+ get grault() {
+ return Promise.resolve("grault");
+ },
+ get garply() {
+ return Promise.reject(new Error("garply"));
+ },
+ };
+ },
+ },
+ };
+
+ const snapshot = await ASRouterTargeting.getEnvironmentSnapshot({
+ targets: [target],
+ });
+ Assert.deepEqual(
+ snapshot,
+ {
+ environment: {
+ foo: "foo",
+ baz: {
+ qux: "qux",
+ corge: {
+ grault: "grault",
+ },
+ },
+ },
+ version: 1,
+ },
+ "getEnvironmentSnapshot should resolve nested promises"
+ );
+});
+
+add_task(async function arrays() {
+ const target = {
+ foo: [1, 2, 3],
+ bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)],
+ baz: Promise.resolve([1, 2, 3]),
+ qux: Promise.resolve([
+ Promise.resolve(1),
+ Promise.resolve(2),
+ Promise.resolve(3),
+ ]),
+ quux: Promise.resolve({
+ corge: [Promise.resolve(1), 2, 3],
+ }),
+ };
+
+ const snapshot = await ASRouterTargeting.getEnvironmentSnapshot({
+ targets: [target],
+ });
+ Assert.deepEqual(
+ snapshot,
+ {
+ environment: {
+ foo: [1, 2, 3],
+ bar: [1, 2, 3],
+ baz: [1, 2, 3],
+ qux: [1, 2, 3],
+ quux: { corge: [1, 2, 3] },
+ },
+ version: 1,
+ },
+ "getEnvironmentSnapshot should resolve arrays correctly"
+ );
+});
+
+add_task(async function target_order() {
+ let target1 = {
+ foo: 1,
+ bar: 1,
+ baz: 1,
+ };
+
+ let target2 = {
+ foo: 2,
+ bar: 2,
+ };
+
+ let target3 = {
+ foo: 3,
+ };
+
+ // target3 supercedes target2; both supercede target1.
+ let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({
+ targets: [target3, target2, target1],
+ });
+ Assert.deepEqual(snapshot, {
+ environment: { foo: 3, bar: 2, baz: 1 },
+ version: 1,
+ });
+});
+
+/*
+ * NB: This test is last because it manipulates shutdown phases.
+ *
+ * Adding tests after this one will result in failures.
+ */
+add_task(async function should_ignore_rejections() {
+ // The order that `ASRouterTargeting.getEnvironmentSnapshot`
+ // enumerates the target object matters here, but it's guaranteed to
+ // be consistent by the `for ... in` ordering: see
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#description.
+ let target = {
+ get foo() {
+ return new Promise(resolve => resolve(1));
+ },
+
+ get bar() {
+ return new Promise(resolve => {
+ // Pretend that we're about to shut down.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWN
+ );
+ resolve(2);
+ });
+ },
+
+ get baz() {
+ return new Promise(resolve => resolve(3));
+ },
+ };
+
+ let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({
+ targets: [target],
+ });
+ // `baz` is dropped since we're shutting down by the time it's processed.
+ Assert.deepEqual(snapshot, { environment: { foo: 1, bar: 2 }, version: 1 });
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js b/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js
new file mode 100644
index 0000000000..bda6d0cd41
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+add_task(async function nested_objects() {
+ const target = {
+ get foo() {
+ return Promise.resolve("foo");
+ },
+ baz: {
+ get qux() {
+ return Promise.resolve("qux");
+ },
+ get corge() {
+ return {
+ get grault() {
+ return Promise.resolve("grault");
+ },
+ };
+ },
+ },
+ };
+
+ const params = await ASRouter.getTargetingParameters(target);
+ Assert.deepEqual(
+ params,
+ {
+ foo: "foo",
+ baz: {
+ qux: "qux",
+ corge: {
+ grault: "grault",
+ },
+ },
+ },
+ "getTargetingParameters should resolve nested promises"
+ );
+});
+
+add_task(async function arrays() {
+ const target = {
+ foo: [1, 2, 3],
+ bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)],
+ baz: Promise.resolve([1, 2, 3]),
+ qux: Promise.resolve([
+ Promise.resolve(1),
+ Promise.resolve(2),
+ Promise.resolve(3),
+ ]),
+ quux: Promise.resolve({
+ corge: [Promise.resolve(1), 2, 3],
+ }),
+ };
+
+ const params = await ASRouter.getTargetingParameters(target);
+ Assert.deepEqual(
+ params,
+ {
+ foo: [1, 2, 3],
+ bar: [1, 2, 3],
+ baz: [1, 2, 3],
+ qux: [1, 2, 3],
+ quux: { corge: [1, 2, 3] },
+ },
+ "getEnvironmentSnapshot should resolve arrays correctly"
+ );
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js
new file mode 100644
index 0000000000..3354013067
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+
+add_task(async function test_cfrMessages() {
+ const { experimentValidator, messageValidators } = await makeValidators();
+
+ const messages = await CFRMessageProvider.getMessages();
+ for (const message of messages) {
+ const validator = messageValidators[message.template];
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}.`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as template ${message.template}`
+ );
+ assertValidates(
+ experimentValidator,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+ }
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js
new file mode 100644
index 0000000000..fce99362c7
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { InflightAssetsMessageProvider } = ChromeUtils.importESModule(
+ "resource://testing-common/InflightAssetsMessageProvider.sys.mjs"
+);
+
+const MESSAGE_VALIDATORS = {};
+let EXPERIMENT_VALIDATOR;
+
+add_setup(async function setup() {
+ const validators = await makeValidators();
+
+ EXPERIMENT_VALIDATOR = validators.experimentValidator;
+ Object.assign(MESSAGE_VALIDATORS, validators.messageValidators);
+});
+
+add_task(function test_InflightAssetsMessageProvider() {
+ const messages = InflightAssetsMessageProvider.getMessages();
+
+ for (const message of messages) {
+ const validator = MESSAGE_VALIDATORS[message.template];
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as ${message.template} template`
+ );
+ assertValidates(
+ EXPERIMENT_VALIDATOR,
+ message,
+ `Message ${message.id} validates as a MessagingExperiment`
+ );
+ }
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js
new file mode 100644
index 0000000000..2fe01e2fed
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { NimbusRolloutMessageProvider } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusRolloutMessageProvider.sys.mjs"
+);
+
+const MESSAGE_VALIDATORS = {};
+let EXPERIMENT_VALIDATOR;
+
+add_setup(async function setup() {
+ const validators = await makeValidators();
+
+ EXPERIMENT_VALIDATOR = validators.experimentValidator;
+ Object.assign(MESSAGE_VALIDATORS, validators.messageValidators);
+});
+
+add_task(function test_NimbusRolloutMessageProvider() {
+ const messages = NimbusRolloutMessageProvider.getMessages();
+
+ for (const message of messages) {
+ const validator = MESSAGE_VALIDATORS[message.template];
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as ${message.template} template`
+ );
+ assertValidates(
+ EXPERIMENT_VALIDATOR,
+ message,
+ `Message ${message.id} validates as a MessagingExperiment`
+ );
+ }
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js
new file mode 100644
index 0000000000..7ea7c97a03
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js
@@ -0,0 +1,229 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { OnboardingMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+function getOnboardingScreenById(screens, screenId) {
+ return screens.find(screen => {
+ return screen?.id === screenId;
+ });
+}
+
+add_task(
+ async function test_OnboardingMessageProvider_getUpgradeMessage_no_pin() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // If Firefox is not pinned, the screen should have "pin" content
+ equal(
+ message.content.screens[0].id,
+ "UPGRADE_PIN_FIREFOX",
+ "Screen has pin screen id"
+ );
+ equal(
+ message.content.screens[0].content.primary_button.action.type,
+ "PIN_FIREFOX_TO_TASKBAR",
+ "Primary button has pin action type"
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function test_OnboardingMessageProvider_getUpgradeMessage_pin_no_default() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false);
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // If Firefox is pinned, but not the default, the screen should have "make default" content
+ equal(
+ message.content.screens[0].id,
+ "UPGRADE_ONLY_DEFAULT",
+ "Screen has make default screen id"
+ );
+ equal(
+ message.content.screens[0].content.primary_button.action.type,
+ "SET_DEFAULT_BROWSER",
+ "Primary button has make default action"
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function test_OnboardingMessageProvider_getUpgradeMessage_pin_and_default() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false);
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(false);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // If Firefox is pinned and the default, the screen should have "get started" content
+ equal(
+ message.content.screens[0].id,
+ "UPGRADE_GET_STARTED",
+ "Screen has get started screen id"
+ );
+ ok(
+ !message.content.screens[0].content.primary_button.action.type,
+ "Primary button has no action type"
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(async function test_OnboardingMessageProvider_getNoImport_default() {
+ let sandbox = sinon.createSandbox();
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(false);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // No import screen is shown when user has Firefox both pinned and default
+ Assert.notEqual(
+ message.content.screens[1]?.id,
+ "UPGRADE_IMPORT_SETTINGS_EMBEDDED",
+ "Screen has no import screen id"
+ );
+ sandbox.restore();
+});
+
+add_task(async function test_OnboardingMessageProvider_getImport_nodefault() {
+ Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser");
+ });
+
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedDefault").resolves(true);
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // Import screen is shown when user doesn't have Firefox pinned and default
+ Assert.equal(
+ message.content.screens[1]?.id,
+ "UPGRADE_IMPORT_SETTINGS_EMBEDDED",
+ "Screen has import screen id"
+ );
+ sandbox.restore();
+});
+
+add_task(
+ async function test_OnboardingMessageProvider_getPinPrivateWindow_noPrivatePin() {
+ Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser");
+ });
+ let sandbox = sinon.createSandbox();
+ // User needs default to ensure Pin Private window shows as third screen after import
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+
+ let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin");
+ pinStub.resolves(false);
+ pinStub.withArgs(true).resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // Pin Private screen is shown when user doesn't have Firefox private pinned but has Firefox pinned
+ Assert.ok(
+ getOnboardingScreenById(
+ message.content.screens,
+ "UPGRADE_PIN_PRIVATE_WINDOW"
+ )
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function test_OnboardingMessageProvider_getNoPinPrivateWindow_noPin() {
+ Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser");
+ });
+ let sandbox = sinon.createSandbox();
+ // User needs default to ensure Pin Private window shows as third screen after import
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+
+ let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin");
+ pinStub.resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // Pin Private screen is not shown when user doesn't have Firefox pinned
+ Assert.ok(
+ !getOnboardingScreenById(
+ message.content.screens,
+ "UPGRADE_PIN_PRIVATE_WINDOW"
+ )
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(async function test_schemaValidation() {
+ const { experimentValidator, messageValidators } = await makeValidators();
+
+ const messages = await OnboardingMessageProvider.getMessages();
+ for (const message of messages) {
+ const validator = messageValidators[message.template];
+
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}.`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as template ${message.template}`
+ );
+ assertValidates(
+ experimentValidator,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+ }
+});
+
+add_task(
+ async function test_OnboardingMessageProvider_getPinPrivateWindow_pinPBMPrefDisabled() {
+ Services.prefs.setBoolPref(
+ "browser.startup.upgradeDialog.pinPBM.disabled",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "browser.startup.upgradeDialog.pinPBM.disabled"
+ );
+ });
+ let sandbox = sinon.createSandbox();
+ // User needs default to ensure Pin Private window shows as third screen after import
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+
+ let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin");
+ pinStub.resolves(true);
+
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // Pin Private screen is not shown when pref is turned on
+ Assert.ok(
+ !getOnboardingScreenById(
+ message.content.screens,
+ "UPGRADE_PIN_PRIVATE_WINDOW"
+ )
+ );
+ sandbox.restore();
+ }
+);
diff --git a/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js
new file mode 100644
index 0000000000..3523355659
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/PanelTestProvider.sys.mjs"
+);
+
+const MESSAGE_VALIDATORS = {};
+let EXPERIMENT_VALIDATOR;
+
+add_setup(async function setup() {
+ const validators = await makeValidators();
+
+ EXPERIMENT_VALIDATOR = validators.experimentValidator;
+ Object.assign(MESSAGE_VALIDATORS, validators.messageValidators);
+});
+
+add_task(async function test_PanelTestProvider() {
+ const messages = await PanelTestProvider.getMessages();
+
+ const EXPECTED_MESSAGE_COUNTS = {
+ cfr_doorhanger: 1,
+ milestone_message: 0,
+ update_action: 1,
+ whatsnew_panel_message: 7,
+ spotlight: 3,
+ feature_callout: 1,
+ pb_newtab: 2,
+ toast_notification: 3,
+ };
+
+ const EXPECTED_TOTAL_MESSAGE_COUNT = Object.values(
+ EXPECTED_MESSAGE_COUNTS
+ ).reduce((a, b) => a + b, 0);
+
+ Assert.strictEqual(
+ messages.length,
+ EXPECTED_TOTAL_MESSAGE_COUNT,
+ "PanelTestProvider should have the correct number of messages"
+ );
+
+ const messageCounts = Object.assign(
+ {},
+ ...Object.keys(EXPECTED_MESSAGE_COUNTS).map(key => ({ [key]: 0 }))
+ );
+
+ for (const message of messages) {
+ const validator = MESSAGE_VALIDATORS[message.template];
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as ${message.template} template`
+ );
+ assertValidates(
+ EXPERIMENT_VALIDATOR,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+
+ messageCounts[message.template]++;
+ }
+
+ for (const [template, count] of Object.entries(messageCounts)) {
+ Assert.equal(
+ count,
+ EXPECTED_MESSAGE_COUNTS[template],
+ `Expected ${EXPECTED_MESSAGE_COUNTS[template]} ${template} messages`
+ );
+ }
+});
+
+add_task(async function test_emptyMessage() {
+ info(
+ "Testing blank FxMS messages validate with the Messaging Experiment schema"
+ );
+
+ assertValidates(EXPERIMENT_VALIDATOR, {}, "Empty message should validate");
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js b/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js
new file mode 100644
index 0000000000..e69ce98677
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { ObjectUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ObjectUtils.sys.mjs"
+);
+
+const MESSAGES = [
+ {
+ trigger: { id: "defaultBrowserCheck" },
+ targeting:
+ "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5",
+ },
+ {
+ groups: ["eco"],
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ targeting:
+ "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5",
+ },
+];
+
+let EXPERIMENT_VALIDATOR;
+
+add_setup(async function setup() {
+ EXPERIMENT_VALIDATOR = await schemaValidatorFor(
+ "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json"
+ );
+});
+
+add_task(function test_reach_experiments_validation() {
+ for (const [index, message] of MESSAGES.entries()) {
+ assertValidates(
+ EXPERIMENT_VALIDATOR,
+ message,
+ `Message ${index} validates as a MessagingExperiment`
+ );
+ }
+});
+
+function depError(has, missing) {
+ return {
+ instanceLocation: "#",
+ keyword: "dependentRequired",
+ keywordLocation: "#/oneOf/1/allOf/0/$ref/dependantRequired",
+ error: `Instance has "${has}" but does not have "${missing}".`,
+ };
+}
+
+function assertContains(haystack, needle) {
+ Assert.ok(
+ haystack.find(item => ObjectUtils.deepEqual(item, needle)) !== null
+ );
+}
+
+add_task(function test_reach_experiment_dependentRequired() {
+ info(
+ "Testing that if id is present then content and template are not required"
+ );
+
+ {
+ const message = {
+ ...MESSAGES[0],
+ id: "message-id",
+ };
+
+ const result = EXPERIMENT_VALIDATOR.validate(message);
+ Assert.ok(result.valid, "message should validate");
+ }
+
+ info("Testing that if content is present then id and template are required");
+ {
+ const message = {
+ ...MESSAGES[0],
+ content: {},
+ };
+
+ const result = EXPERIMENT_VALIDATOR.validate(message);
+ Assert.ok(!result.valid, "message should not validate");
+ assertContains(result.errors, depError("content", "id"));
+ assertContains(result.errors, depError("content", "template"));
+ }
+
+ info("Testing that if template is present then id and content are required");
+ {
+ const message = {
+ ...MESSAGES[0],
+ template: "cfr",
+ };
+
+ const result = EXPERIMENT_VALIDATOR.validate(message);
+ Assert.ok(!result.valid, "message should not validate");
+ assertContains(result.errors, depError("template", "content"));
+ assertContains(result.errors, depError("template", "id"));
+ }
+});
diff --git a/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js b/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js
new file mode 100644
index 0000000000..40c0993b4f
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+
+add_task(async function test_multiMessageTreatment() {
+ const { experimentValidator } = await makeValidators();
+ // Use the entire list of messages as if it was a single treatment branch's
+ // feature value.
+ let messages = await CFRMessageProvider.getMessages();
+ let featureValue = { template: "multi", messages };
+ assertValidates(
+ experimentValidator,
+ featureValue,
+ `Multi-message treatment validates as MessagingExperiment`
+ );
+ for (const message of messages) {
+ assertValidates(
+ experimentValidator,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+ }
+
+ // Add an invalid message to the list and make sure it fails validation.
+ messages.push({
+ id: "INVALID_MESSAGE",
+ template: "cfr_doorhanger",
+ });
+ const result = experimentValidator.validate(featureValue);
+ Assert.ok(
+ !(result.valid && result.errors.length === 0),
+ "Multi-message treatment with invalid message fails validation"
+ );
+});
diff --git a/browser/components/asrouter/tests/xpcshell/xpcshell.toml b/browser/components/asrouter/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..db28042ad2
--- /dev/null
+++ b/browser/components/asrouter/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,24 @@
+[DEFAULT]
+head = "head.js"
+firefox-appdir = "browser"
+
+["test_ASRouterTargeting_attribution.js"]
+run-if = ["os == 'mac'"] # osx specific tests
+
+["test_ASRouterTargeting_snapshot.js"]
+
+["test_ASRouter_getTargetingParameters.js"]
+
+["test_CFRMessageProvider.js"]
+
+["test_InflightAssetsMessageProvider.js"]
+
+["test_NimbusRolloutMessageProvider.js"]
+
+["test_OnboardingMessageProvider.js"]
+
+["test_PanelTestProvider.js"]
+
+["test_reach_experiments.js"]
+
+["test_remoteExperiments.js"]