summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/messaging-system')
-rw-r--r--toolkit/components/messaging-system/jar.mn8
-rw-r--r--toolkit/components/messaging-system/lib/Logger.jsm23
-rw-r--r--toolkit/components/messaging-system/lib/SpecialMessageActions.jsm404
-rw-r--r--toolkit/components/messaging-system/moz.build24
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json558
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md322
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini33
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js21
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js17
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_block_message.js23
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js14
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js31
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_click_element.js166
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js114
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js22
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js28
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js32
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_handle_multiaction.js72
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js34
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js9
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefox_view.js70
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefoxview_colorways_modal.js29
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js17
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js22
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js29
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_spotlight_dialog.js38
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js33
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js14
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_firefox.js72
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_private_firefox.js78
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_set_prefs.js106
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js66
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js43
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js64
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json261
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md162
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini7
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js74
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js501
-rw-r--r--toolkit/components/messaging-system/schemas/index.rst187
-rw-r--r--toolkit/components/messaging-system/targeting/Targeting.jsm251
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/head.js4
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/test_targeting.js327
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini6
44 files changed, 4416 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/jar.mn b/toolkit/components/messaging-system/jar.mn
new file mode 100644
index 0000000000..6bd8e180af
--- /dev/null
+++ b/toolkit/components/messaging-system/jar.mn
@@ -0,0 +1,8 @@
+# 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/.
+
+toolkit.jar:
+% resource messaging-system %res/messaging-system/
+ res/messaging-system/lib/ (./lib/*)
+ res/messaging-system/targeting/Targeting.jsm (./targeting/Targeting.jsm)
diff --git a/toolkit/components/messaging-system/lib/Logger.jsm b/toolkit/components/messaging-system/lib/Logger.jsm
new file mode 100644
index 0000000000..475439966e
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/Logger.jsm
@@ -0,0 +1,23 @@
+/* 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";
+
+const EXPORTED_SYMBOLS = ["Logger"];
+
+const { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+);
+
+const LOGGING_PREF = "messaging-system.log";
+
+class Logger extends ConsoleAPI {
+ constructor(name) {
+ let consoleOptions = {
+ prefix: name,
+ maxLogLevel: Services.prefs.getCharPref(LOGGING_PREF, "warn"),
+ maxLogLevelPref: LOGGING_PREF,
+ };
+ super(consoleOptions);
+ }
+}
diff --git a/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm b/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm
new file mode 100644
index 0000000000..81eec6822f
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm
@@ -0,0 +1,404 @@
+/* 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";
+
+const EXPORTED_SYMBOLS = ["SpecialMessageActions"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+const NETWORK_TRR_MODE_PREF = "network.trr.mode";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+ UITour: "resource:///modules/UITour.jsm",
+ FxAccounts: "resource://gre/modules/FxAccounts.jsm",
+ Spotlight: "resource://activity-stream/lib/Spotlight.jsm",
+ ColorwayClosetOpener: "resource:///modules/ColorwayClosetOpener.jsm",
+});
+
+const SpecialMessageActions = {
+ // This is overridden by ASRouter.init
+ blockMessageById() {
+ throw new Error("ASRouter not intialized yet");
+ },
+
+ /**
+ * loadAddonIconInURLBar - load addons-notification icon by displaying
+ * box containing addons icon in urlbar. See Bug 1513882
+ *
+ * @param {Browser} browser browser element for showing addons icon
+ */
+ loadAddonIconInURLBar(browser) {
+ if (!browser) {
+ return;
+ }
+ const chromeDoc = browser.ownerDocument;
+ let notificationPopupBox = chromeDoc.getElementById(
+ "notification-popup-box"
+ );
+ if (!notificationPopupBox) {
+ return;
+ }
+ if (
+ notificationPopupBox.style.display === "none" ||
+ notificationPopupBox.style.display === ""
+ ) {
+ notificationPopupBox.style.display = "block";
+ }
+ },
+
+ /**
+ *
+ * @param {Browser} browser The revelant Browser
+ * @param {string} url URL to look up install location
+ * @param {string} telemetrySource Telemetry information to pass to getInstallForURL
+ */
+ async installAddonFromURL(browser, url, telemetrySource = "amo") {
+ try {
+ this.loadAddonIconInURLBar(browser);
+ const aUri = Services.io.newURI(url);
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+ // AddonManager installation source associated to the addons installed from activitystream's CFR
+ // and RTAMO (source is going to be "amo" if not configured explicitly in the message provider).
+ const telemetryInfo = { source: telemetrySource };
+ const install = await lazy.AddonManager.getInstallForURL(aUri.spec, {
+ telemetryInfo,
+ });
+ await lazy.AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ browser,
+ systemPrincipal,
+ install
+ );
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ },
+
+ /**
+ * Pin Firefox to taskbar.
+ *
+ * @param {Window} window Reference to a window object
+ * @param {boolean} pin Private Browsing Mode if true
+ */
+ pinFirefoxToTaskbar(window, privateBrowsing = false) {
+ return window.getShellService().pinToTaskbar(privateBrowsing);
+ },
+
+ /**
+ * Set browser as the operating system default browser.
+ *
+ * @param {Window} window Reference to a window object
+ */
+ setDefaultBrowser(window) {
+ window.getShellService().setAsDefault();
+ },
+
+ /**
+ * Reset browser homepage and newtab to default with a certain section configuration
+ *
+ * @param {"default"|null} home Value to set for browser homepage
+ * @param {"default"|null} newtab Value to set for browser newtab
+ * @param {obj} layout Configuration options for newtab sections
+ * @returns {undefined}
+ */
+ configureHomepage({ homePage = null, newtab = null, layout = null }) {
+ // Homepage can be default, blank or a custom url
+ if (homePage === "default") {
+ Services.prefs.clearUserPref("browser.startup.homepage");
+ }
+ // Newtab page can only be default or blank
+ if (newtab === "default") {
+ Services.prefs.clearUserPref("browser.newtabpage.enabled");
+ }
+ if (layout) {
+ // Existing prefs that interact with the newtab page layout, we default to true
+ // or payload configuration
+ let newtabConfigurations = [
+ [
+ // controls the search bar
+ "browser.newtabpage.activity-stream.showSearch",
+ layout.search,
+ ],
+ [
+ // controls the topsites
+ "browser.newtabpage.activity-stream.feeds.topsites",
+ layout.topsites,
+ // User can control number of topsite rows
+ ["browser.newtabpage.activity-stream.topSitesRows"],
+ ],
+ [
+ // controls the highlights section
+ "browser.newtabpage.activity-stream.feeds.section.highlights",
+ layout.highlights,
+ // User can control number of rows and highlight sources
+ [
+ "browser.newtabpage.activity-stream.section.highlights.rows",
+ "browser.newtabpage.activity-stream.section.highlights.includeVisited",
+ "browser.newtabpage.activity-stream.section.highlights.includePocket",
+ "browser.newtabpage.activity-stream.section.highlights.includeDownloads",
+ "browser.newtabpage.activity-stream.section.highlights.includeBookmarks",
+ ],
+ ],
+ [
+ // controls the snippets section
+ "browser.newtabpage.activity-stream.feeds.snippets",
+ layout.snippets,
+ ],
+ [
+ // controls the topstories section
+ "browser.newtabpage.activity-stream.feeds.system.topstories",
+ layout.topstories,
+ ],
+ ].filter(
+ // If a section has configs that the user changed we will skip that section
+ ([, , sectionConfigs]) =>
+ !sectionConfigs ||
+ sectionConfigs.every(
+ prefName => !Services.prefs.prefHasUserValue(prefName)
+ )
+ );
+
+ for (let [prefName, prefValue] of newtabConfigurations) {
+ Services.prefs.setBoolPref(prefName, prefValue);
+ }
+ }
+ },
+
+ /**
+ * Set prefs with special message actions
+ *
+ * @param {Object} pref - A pref to be updated.
+ * @param {string} pref.name - The name of the pref to be updated
+ * @param {string} [pref.value] - The value of the pref to be updated. If not included, the pref will be reset.
+ */
+ setPref(pref) {
+ // Array of prefs that are allowed to be edited by SET_PREF
+ const allowedPrefs = [
+ "browser.dataFeatureRecommendations.enabled",
+ "browser.startup.homepage",
+ "browser.privateWindowSeparation.enabled",
+ "browser.firefox-view.feature-tour",
+ "browser.pdfjs.feature-tour",
+ ];
+
+ if (!allowedPrefs.includes(pref.name)) {
+ throw new Error(
+ `Special message action with type SET_PREF and pref of "${pref.name}" is unsupported.`
+ );
+ }
+ // If pref has no value, reset it, otherwise set it to desired value
+ switch (typeof pref.value) {
+ case "object":
+ case "undefined":
+ Services.prefs.clearUserPref(pref.name);
+ break;
+ case "string":
+ Services.prefs.setStringPref(pref.name, pref.value);
+ break;
+ case "number":
+ Services.prefs.setIntPref(pref.name, pref.value);
+ break;
+ case "boolean":
+ Services.prefs.setBoolPref(pref.name, pref.value);
+ break;
+ default:
+ throw new Error(
+ `Special message action with type SET_PREF, pref of "${pref.name}" is an unsupported type.`
+ );
+ }
+ },
+
+ /**
+ * Processes "Special Message Actions", which are definitions of behaviors such as opening tabs
+ * installing add-ons, or focusing the awesome bar that are allowed to can be triggered from
+ * Messaging System interactions.
+ *
+ * @param {{type: string, data?: any}} action User action defined in message JSON.
+ * @param browser {Browser} The browser most relevant to the message.
+ */
+ async handleAction(action, browser) {
+ const window = browser.ownerGlobal;
+ switch (action.type) {
+ case "SHOW_MIGRATION_WIZARD":
+ Services.tm.dispatchToMainThread(() =>
+ lazy.MigrationUtils.showMigrationWizard(window, {
+ entrypoint: lazy.MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB,
+ migratorKey: action.data?.source,
+ })
+ );
+ break;
+ case "OPEN_PRIVATE_BROWSER_WINDOW":
+ // Forcefully open about:privatebrowsing
+ window.OpenBrowserWindow({ private: true });
+ break;
+ case "OPEN_URL":
+ window.openLinkIn(
+ Services.urlFormatter.formatURL(action.data.args),
+ action.data.where || "current",
+ {
+ private: false,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ csp: null,
+ }
+ );
+ break;
+ case "OPEN_ABOUT_PAGE":
+ let aboutPageURL = new URL(`about:${action.data.args}`);
+ if (action.data.entrypoint) {
+ aboutPageURL.search = action.data.entrypoint;
+ }
+ window.openTrustedLinkIn(
+ aboutPageURL.toString(),
+ action.data.where || "tab"
+ );
+ break;
+ case "OPEN_FIREFOX_VIEW":
+ window.FirefoxViewHandler.openTab();
+ break;
+ case "OPEN_PREFERENCES_PAGE":
+ window.openPreferences(
+ action.data.category || action.data.args,
+ action.data.entrypoint && {
+ urlParams: { entrypoint: action.data.entrypoint },
+ }
+ );
+ break;
+ case "OPEN_APPLICATIONS_MENU":
+ lazy.UITour.showMenu(window, action.data.args);
+ break;
+ case "HIGHLIGHT_FEATURE":
+ const highlight = await lazy.UITour.getTarget(window, action.data.args);
+ if (highlight) {
+ await lazy.UITour.showHighlight(window, highlight, "none", {
+ autohide: true,
+ });
+ }
+ break;
+ case "INSTALL_ADDON_FROM_URL":
+ await this.installAddonFromURL(
+ browser,
+ action.data.url,
+ action.data.telemetrySource
+ );
+ break;
+ case "PIN_FIREFOX_TO_TASKBAR":
+ await this.pinFirefoxToTaskbar(window, action.data?.privatePin);
+ break;
+ case "PIN_AND_DEFAULT":
+ await this.pinFirefoxToTaskbar(window, action.data?.privatePin);
+ this.setDefaultBrowser(window);
+ break;
+ case "SET_DEFAULT_BROWSER":
+ this.setDefaultBrowser(window);
+ break;
+ case "PIN_CURRENT_TAB":
+ let tab = window.gBrowser.selectedTab;
+ window.gBrowser.pinTab(tab);
+ window.ConfirmationHint.show(tab, "confirmation-hint-pin-tab", {
+ descriptionId: "confirmation-hint-pin-tab-description",
+ });
+ break;
+ case "SHOW_FIREFOX_ACCOUNTS":
+ if (!(await lazy.FxAccounts.canConnectAccount())) {
+ break;
+ }
+ const data = action.data;
+ const url = await lazy.FxAccounts.config.promiseConnectAccountURI(
+ (data && data.entrypoint) || "snippets",
+ (data && data.extraParams) || {}
+ );
+ // Use location provided; if not specified, replace the current tab.
+ window.openLinkIn(url, data.where || "current", {
+ private: false,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ csp: null,
+ });
+ break;
+ case "OPEN_PROTECTION_PANEL":
+ let { gProtectionsHandler } = window;
+ gProtectionsHandler.showProtectionsPopup({});
+ break;
+ case "OPEN_PROTECTION_REPORT":
+ window.gProtectionsHandler.openProtections();
+ break;
+ case "OPEN_AWESOME_BAR":
+ window.gURLBar.search("");
+ break;
+ case "DISABLE_STP_DOORHANGERS":
+ await this.blockMessageById([
+ "SOCIAL_TRACKING_PROTECTION",
+ "FINGERPRINTERS_PROTECTION",
+ "CRYPTOMINERS_PROTECTION",
+ ]);
+ break;
+ case "DISABLE_DOH":
+ Services.prefs.setStringPref(
+ DOH_DOORHANGER_DECISION_PREF,
+ "UIDisabled"
+ );
+ Services.prefs.setIntPref(NETWORK_TRR_MODE_PREF, 5);
+ break;
+ case "ACCEPT_DOH":
+ Services.prefs.setStringPref(DOH_DOORHANGER_DECISION_PREF, "UIOk");
+ break;
+ case "CANCEL":
+ // A no-op used by CFRs that minimizes the notification but does not
+ // trigger a dismiss or block (it keeps the notification around)
+ break;
+ case "CONFIGURE_HOMEPAGE":
+ this.configureHomepage(action.data);
+ break;
+ case "SHOW_SPOTLIGHT":
+ lazy.Spotlight.showSpotlightDialog(browser, action.data);
+ break;
+ case "BLOCK_MESSAGE":
+ await this.blockMessageById(action.data.id);
+ break;
+ case "SET_PREF":
+ this.setPref(action.data.pref);
+ break;
+ case "MULTI_ACTION":
+ await Promise.all(
+ action.data.actions.map(async action => {
+ try {
+ await this.handleAction(action, browser);
+ } catch (err) {
+ throw new Error(`Error in MULTI_ACTION event: ${err.message}`);
+ }
+ })
+ );
+ break;
+ default:
+ throw new Error(
+ `Special message action with type ${action.type} is unsupported.`
+ );
+ case "CLICK_ELEMENT":
+ const clickElement = window.document.querySelector(
+ action.data.selector
+ );
+ clickElement?.click();
+ break;
+ case "OPEN_FIREFOX_VIEW_AND_COLORWAYS_MODAL":
+ window.FirefoxViewHandler.openTab();
+ lazy.ColorwayClosetOpener.openModal({
+ source: "firefoxview",
+ });
+ break;
+ }
+ },
+};
diff --git a/toolkit/components/messaging-system/moz.build b/toolkit/components/messaging-system/moz.build
new file mode 100644
index 0000000000..f1100218c8
--- /dev/null
+++ b/toolkit/components/messaging-system/moz.build
@@ -0,0 +1,24 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Messaging System")
+
+BROWSER_CHROME_MANIFESTS += [
+ "schemas/SpecialMessageActionSchemas/test/browser/browser.ini",
+ "schemas/TriggerActionSchemas/test/browser/browser.ini",
+]
+
+SPHINX_TREES["docs"] = "schemas"
+
+XPCSHELL_TESTS_MANIFESTS += ["targeting/test/unit/xpcshell.ini"]
+
+TESTING_JS_MODULES += [
+ "schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json",
+ "schemas/TriggerActionSchemas/TriggerActionSchemas.json",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json
new file mode 100644
index 0000000000..c67c4fd483
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json
@@ -0,0 +1,558 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$ref": "#/definitions/SpecialMessageActionSchemas",
+ "definitions": {
+ "SpecialMessageActionSchemas": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["DISABLE_STP_DOORHANGERS"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Disables all STP doorhangers."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "The element to highlight"
+ }
+ },
+ "required": ["args"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["HIGHLIGHT_FEATURE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Highlights an element, such as a menu item"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "telemetrySource": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": ["telemetrySource", "url"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["INSTALL_ADDON_FROM_URL"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Install an add-on from AMO"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "The about page. E.g. \"welcome\" for about:welcome'"
+ },
+ "where": {
+ "type": "string",
+ "enum": ["current", "save", "tab", "tabshifted", "window"],
+ "description": "Where the URL is opened",
+ "default": "tab"
+ },
+ "entrypoint": {
+ "type": "string",
+ "description": "Any optional entrypoint value that will be added to the search. E.g. \"foo=bar\" would result in about:welcome?foo=bar'"
+ }
+ },
+ "required": ["args", "where", "entrypoint"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_ABOUT_PAGE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens an about: page in Firefox"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_FIREFOX_VIEW"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Opens the Firefox View pseudo-pinned-tab"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "The menu name, e.g. \"appMenu\""
+ }
+ },
+ "required": ["args"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_APPLICATIONS_MENU"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens an application menu"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_AWESOME_BAR"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Focuses and expands the awesome bar"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Section of about:preferences, e.g. \"privacy-reports\""
+ },
+ "entrypoint": {
+ "type": "string",
+ "description": "Add a queryparam for metrics"
+ }
+ },
+ "required": ["category"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PREFERENCES_PAGE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens a preference page"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PRIVATE_BROWSER_WINDOW"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Opens a private browsing window."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PROTECTION_PANEL"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Opens the protections panel"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PROTECTION_REPORT"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Opens the protections panel report"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "URL to open"
+ },
+ "where": {
+ "type": "string",
+ "enum": ["current", "save", "tab", "tabshifted", "window"],
+ "description": "Where the URL is opened",
+ "default": "tab"
+ }
+ },
+ "required": ["args", "where"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_URL"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens given URL"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["PIN_CURRENT_TAB"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Pin the current tab"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["SHOW_FIREFOX_ACCOUNTS"]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "entrypoint": {
+ "type": "string",
+ "description": "Adds entrypoint={your value} to the FXA URL"
+ },
+ "extraParams": {
+ "type": "object",
+ "description": "Any extra parameter that will be added to the FXA URL. E.g. {foo: bar} would result in <FXA_url>?foo=bar'"
+ }
+ },
+ "required": ["entrypoint"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["type", "data"],
+ "additionalProperties": false,
+ "description": "Show Firefox Accounts"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["SHOW_MIGRATION_WIZARD"]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "description": "Identitifer of the browser that should be pre-selected in the import migration wizard popup (e.g. 'chrome'), See https://searchfox.org/mozilla-central/rev/8dae1cc76a6b45e05198bc0d5d4edb7bf1003265/browser/components/migration/MigrationUtils.jsm#917"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Shows the Migration Wizard to import data from another Browser. See https://support.mozilla.org/en-US/kb/import-data-another-browser\""
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["CANCEL"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Minimize the CFR doorhanger back into the URLbar"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["ACCEPT_DOH"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Accept DOH doorhanger notification"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["DISABLE_DOH"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Dismiss DOH doorhanger notification"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["PIN_FIREFOX_TO_TASKBAR"]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "privatePin": {
+ "type": "boolean",
+ "description": "Whether or not to pin private browsing mode"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Pin the app to taskbar"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["SET_DEFAULT_BROWSER"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Message action to set Firefox as default browser"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "homePage": {
+ "type": "string",
+ "description": "Should reset homepage pref",
+ "enum": ["default"]
+ },
+ "newtab": {
+ "type": "string",
+ "enum": ["default"],
+ "description": "Should reset newtab pref"
+ },
+ "layout": {
+ "type": "object",
+ "description": "Section name and boolean value that specifies if the section should be on or off.",
+ "properties": {
+ "search": {
+ "type": "boolean"
+ },
+ "topsites": {
+ "type": "boolean"
+ },
+ "highlights": {
+ "type": "boolean"
+ },
+ "snippets": {
+ "type": "boolean"
+ },
+ "topstories": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "search",
+ "topsites",
+ "highlights",
+ "snippets",
+ "topstories"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["CONFIGURE_HOMEPAGE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Resets homepage pref and sections layout"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "description": "Object containing content rendered inside spotlight dialog"
+ }
+ },
+ "required": ["content"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["SHOW_SPOTLIGHT"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens a spotlight dialog"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Message id to block"
+ }
+ },
+ "required": ["id"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["BLOCK_MESSAGE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Add message to an indexedDb list of blocked messages"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "pref": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": ["boolean", "string", "number", "null"]
+ }
+ },
+ "description": "An object representing a pref containing a name and a value."
+ }
+ },
+ "required": ["pref"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["SET_PREF"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Sets prefs from special message actions"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "actions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "description": "A special message action definition"
+ }
+ }
+ },
+ "type": {
+ "type": "string",
+ "enum": ["MULTI_ACTION"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Runs multiple actions"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "selector": {
+ "type": "string",
+ "description": "A CSS selector for the HTML element to be clicked"
+ }
+ },
+ "type": {
+ "type": "string",
+ "enum": ["CLICK_ELEMENT"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Selects an element in the current Window's document and triggers a click action"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_FIREFOX_VIEW_AND_COLORWAYS_MODAL"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Message action that opens about:firefoxview and renders the colorways modal"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md
new file mode 100644
index 0000000000..5b61021a64
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md
@@ -0,0 +1,322 @@
+# User Actions
+
+A subset of actions are available to messages via fields like `button_action` for snippets, or `primary_action` for CFRs.
+
+## Usage
+
+For snippets, you should add the action type in `button_action` and any additional parameters in `button_action_args. For example:
+
+```json
+{
+ "button_action": "OPEN_ABOUT_PAGE",
+ "button_action_args": "config"
+}
+```
+
+## Available Actions
+
+### `OPEN_APPLICATIONS_MENU`
+
+* args: (none)
+
+Opens the applications menu.
+
+### `OPEN_FIREFOX_VIEW`
+
+* args: (none)
+
+Opens the Firefox View pseudo-tab.
+
+### `OPEN_PRIVATE_BROWSER_WINDOW`
+
+* args: (none)
+
+Opens a new private browsing window.
+
+
+### `OPEN_URL`
+
+* args: `string` (a url)
+
+Opens a given url.
+
+Example:
+
+```json
+{
+ "button_action": "OPEN_URL",
+ "button_action_args": "https://foo.com"
+}
+```
+
+### `OPEN_ABOUT_PAGE`
+
+* args:
+```ts
+{
+ args: string, // (a valid about page without the `about:` prefix)
+ entrypoint?: string, // URL search param used for referrals
+}
+```
+
+Opens a given about page
+
+Example:
+
+```json
+{
+ "button_action": "OPEN_ABOUT_PAGE",
+ "button_action_args": "config"
+}
+```
+
+### `OPEN_PREFERENCES_PAGE`
+
+* args:
+```
+{
+ args?: string, // (a category accessible via a `#`)
+ entrypoint?: string // URL search param used to referrals
+
+Opens `about:preferences` with an optional category accessible via a `#` in the URL (e.g. `about:preferences#home`).
+
+Example:
+
+```json
+{
+ "button_action": "OPEN_PREFERENCES_PAGE",
+ "button_action_args": "home"
+}
+```
+
+### `SHOW_FIREFOX_ACCOUNTS`
+
+* args: (none)
+
+Opens Firefox accounts sign-up page. Encodes some information that the origin was from snippets by default.
+
+### `SHOW_MIGRATION_WIZARD`
+
+* args: (none)
+
+Opens import wizard to bring in settings and data from another browser.
+
+### `PIN_CURRENT_TAB`
+
+* args: (none)
+
+Pins the currently focused tab.
+
+### `ENABLE_FIREFOX_MONITOR`
+
+* args:
+```ts
+{
+ url: string;
+ flowRequestParams: {
+ entrypoint: string;
+ utm_term: string;
+ form_type: string;
+ }
+}
+```
+
+Opens an oauth flow to enable Firefox Monitor at a given `url` and adds Firefox metrics that user given a set of `flowRequestParams`.
+
+#### `url`
+
+The URL should start with `https://monitor.firefox.com/oauth/init` and add various metrics tags as search params, including:
+
+* `utm_source`
+* `utm_campaign`
+* `form_type`
+* `entrypoint`
+
+You should verify the values of these search params with whoever is doing the data analysis (e.g. Leif Oines).
+
+#### `flowRequestParams`
+
+These params are used by Firefox to add information specific to that individual user to the final oauth URL. You should include:
+
+* `entrypoint`
+* `utm_term`
+* `form_type`
+
+The `entrypoint` and `form_type` values should match the encoded values in your `url`.
+
+You should verify the values with whoever is doing the data analysis (e.g. Leif Oines).
+
+#### Example
+
+```json
+{
+ "button_action": "ENABLE_FIREFOX_MONITOR",
+ "button_action_args": {
+ "url": "https://monitor.firefox.com/oauth/init?utm_source=snippets&utm_campaign=monitor-snippet-test&form_type=email&entrypoint=newtab",
+ "flowRequestParams": {
+ "entrypoint": "snippets",
+ "utm_term": "monitor",
+ "form_type": "email"
+ }
+ }
+}
+```
+
+### `HIGHLIGHT_FEATURE`
+
+Can be used to highlight (show a light blue overlay) a certain button or part of the browser UI.
+
+* args: `string` a [valid targeting defined in the UITour](https://searchfox.org/mozilla-central/rev/7fd1c1c34923ece7ad8c822bee062dd0491d64dc/browser/components/uitour/UITour.jsm#108)
+
+### `INSTALL_ADDON_FROM_URL`
+
+Can be used to install an addon from addons.mozilla.org.
+
+* args:
+```ts
+{
+ url: string,
+ telemetrySource?: string
+};
+```
+
+### `OPEN_PROTECTION_REPORT`
+
+Opens `about:protections`
+
+### `OPEN_PROTECTION_PANEL`
+
+Opens the protection panel behind on the lock icon of the awesomebar
+
+### `DISABLE_STP_DOORHANGERS`
+
+Disables all Social Tracking Protection messages
+
+* args: (none)
+
+### `OPEN_AWESOME_BAR`
+
+Focuses and expands the awesome bar.
+
+* args: (none)
+
+### `CANCEL`
+
+No-op action used to dismiss CFR notifications (but not remove or block them)
+
+* args: (none)
+
+### `DISABLE_DOH`
+
+User action for turning off the DoH feature
+
+* args: (none)
+
+### `ACCEPT_DOH`
+
+User action for continuing to use the DoH feature
+
+* args: (none)
+
+### `CONFIGURE_HOMEPAGE`
+
+Action for configuring the user homepage and restoring defaults.
+
+* args:
+```ts
+{
+ homePage: "default" | null;
+ newtab: "default" | null;
+ layout: {
+ search: boolean;
+ topsites: boolean;
+ highlights: boolean;
+ topstories: boolean;
+ snippets: boolean;
+ }
+}
+```
+
+### `PIN_FIREFOX_TO_TASKBAR`
+
+Action for pinning Firefox to the user's taskbar.
+
+* args: (none)
+
+### `SET_DEFAULT_BROWSER`
+
+Action for configuring the default browser to Firefox on the user's system.
+
+- args: (none)
+
+### `SHOW_SPOTLIGHT`
+
+Action for opening a spotlight tab or window modal using the content passed to the dialog.
+
+### `BLOCK_MESSAGE`
+
+Disable a message by adding to an indexedDb list of blocked messages
+
+* args: `string` id of the message
+
+### `SET_PREF`
+
+Action for setting various browser prefs
+
+Prefs that can be changed with this action are:
+
+- `browser.dataFeatureRecommendations.enabled`
+- `browser.privateWindowSeparation.enabled`
+- `browser.startup.homepage`
+
+* args:
+```ts
+{
+ pref: {
+ name: string;
+ value: string | boolean | number;
+ }
+}
+```
+
+### `MULTI_ACTION`
+
+Action for running multiple actions. Actions should be included in an array of actions.
+
+* args:
+```ts
+{
+ actions: Array<UserAction>
+}
+```
+
+* example:
+```json
+{
+ "button_action": "MULTI_ACTION",
+ "button_action_args": {
+ "actions": [
+ {
+ "type": "OPEN_URL",
+ "args": "https://www.example.com"
+ },
+ {
+ "type": "OPEN_AWESOME_BAR"
+ }
+ ]
+ }
+}
+```
+
+### `CLICK_ELEMENT`
+
+* args: `string` A CSS selector for the HTML element to be clicked
+
+Selects an element in the current Window's document and triggers a click action
+
+
+### `OPEN_FIREFOX_VIEW_AND_COLORWAYS_MODAL`
+
+* args: (none)
+
+Action for opening about:firefoxview and the colorways modal
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini
new file mode 100644
index 0000000000..d516bb2bae
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini
@@ -0,0 +1,33 @@
+[DEFAULT]
+prefs =
+ identity.fxaccounts.remote.root=https://example.com/
+support-files =
+ head.js
+ ../../index.md
+
+[browser_sma_block_message.js]
+[browser_sma_open_about_page.js]
+[browser_sma_open_awesome_bar.js]
+[browser_sma_open_firefox_view.js]
+[browser_sma_open_private_browser_window.js]
+[browser_sma_open_protection_panel.js]
+[browser_sma_open_protection_report.js]
+[browser_sma_open_url.js]
+[browser_sma_open_spotlight_dialog.js]
+[browser_sma_pin_current_tab.js]
+[browser_sma_pin_firefox.js]
+[browser_sma_pin_private_firefox.js]
+skip-if = os != "win"
+[browser_sma_show_firefox_accounts.js]
+[browser_sma_show_migration_wizard.js]
+[browser_sma.js]
+[browser_sma_docs.js]
+[browser_sma_accept_doh.js]
+[browser_sma_disable_doh.js]
+[browser_sma_cfrmessageprovider.js]
+[browser_sma_configure_homepage.js]
+[browser_sma_default_browser.js]
+[browser_sma_set_prefs.js]
+[browser_sma_click_element.js]
+[browser_sma_handle_multiaction.js]
+[browser_sma_open_firefoxview_colorways_modal.js]
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js
new file mode 100644
index 0000000000..b46b3730e9
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_unknown_event() {
+ let error;
+ try {
+ await SpecialMessageActions.handleAction(
+ { type: "UNKNOWN_EVENT_123" },
+ gBrowser
+ );
+ } catch (e) {
+ error = e;
+ }
+ ok(error, "should throw if an unexpected event is handled");
+ Assert.equal(
+ error.message,
+ "Special message action with type UNKNOWN_EVENT_123 is unsupported."
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js
new file mode 100644
index 0000000000..f9255b17ec
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+
+add_task(async function test_disable_doh() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[DOH_DOORHANGER_DECISION_PREF, ""]],
+ });
+ await SMATestUtils.executeAndValidateAction({ type: "ACCEPT_DOH" });
+ Assert.equal(
+ Services.prefs.getStringPref(DOH_DOORHANGER_DECISION_PREF, ""),
+ "UIOk",
+ "Pref should be set on accept"
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_block_message.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_block_message.js
new file mode 100644
index 0000000000..0671253b43
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_block_message.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_block_message() {
+ let blockStub = sinon.stub(SpecialMessageActions, "blockMessageById");
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "BLOCK_MESSAGE",
+ data: {
+ id: "TEST_MESSAGE_ID",
+ },
+ });
+
+ Assert.equal(blockStub.callCount, 1, "blockMessageById called by the action");
+ Assert.equal(
+ blockStub.firstCall.args[0],
+ "TEST_MESSAGE_ID",
+ "Argument is message id"
+ );
+ blockStub.restore();
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js
new file mode 100644
index 0000000000..ca42dac563
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_cancel_event() {
+ let error = null;
+ try {
+ await SMATestUtils.executeAndValidateAction({ type: "CANCEL" });
+ } catch (e) {
+ error = e;
+ }
+ ok(!error, "should not throw for CANCEL");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js
new file mode 100644
index 0000000000..93f3cc851f
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { CFRMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/CFRMessageProvider.jsm"
+);
+
+add_task(async function test_all_test_messages() {
+ let messagesWithButtons = (await CFRMessageProvider.getMessages()).filter(
+ m => m.content.buttons
+ );
+
+ for (let message of messagesWithButtons) {
+ info(`Testing ${message.id}`);
+ if (message.template === "infobar") {
+ for (let button of message.content.buttons) {
+ await SMATestUtils.validateAction(button.action);
+ }
+ } else {
+ let { primary, secondary } = message.content.buttons;
+ await SMATestUtils.validateAction(primary.action);
+ for (let secondaryBtn of secondary) {
+ if (secondaryBtn.action) {
+ await SMATestUtils.validateAction(secondaryBtn.action);
+ }
+ }
+ }
+ }
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_click_element.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_click_element.js
new file mode 100644
index 0000000000..085fc1a1a9
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_click_element.js
@@ -0,0 +1,166 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+const TEST_MESSAGE = {
+ message: {
+ template: "feature_callout",
+ content: {
+ id: "TEST_MESSAGE",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ screens: [
+ {
+ id: "TEST_SCREEN_ID",
+ parent_selector: "#tabpickup-steps",
+ content: {
+ position: "callout",
+ arrow_position: "top",
+ title: {
+ string_id: "Test",
+ },
+ subtitle: {
+ string_id: "Test",
+ },
+ primary_button: {
+ label: {
+ string_id: "Test",
+ },
+ action: {
+ type: "CLICK_ELEMENT",
+ data: {
+ selector:
+ "#tab-pickup-container button.primary:not(#error-state-button)",
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+/**
+ * Like in ./browser_sma_open_firefox_view.js,
+ * the setup code and the utility funcitons here are cribbed
+ * from (mostly) browser/components/firefoxview/test/browser/head.js
+ *
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1784979 has been filed to move
+ * these to some place publically accessible, after which we will be able to
+ * a bunch of code from this file.
+ */
+
+let sandbox;
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view", true]],
+ });
+
+ sandbox = sinon.createSandbox();
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ sandbox.restore();
+ });
+});
+
+async function withFirefoxView({ win = null }, taskFn) {
+ let shouldCloseWin = false;
+ if (!win) {
+ win = await BrowserTestUtils.openNewBrowserWindow();
+ shouldCloseWin = true;
+ }
+ let tab = await openFirefoxViewTab(win);
+ let originalWindow = tab.ownerGlobal;
+ let result = await taskFn(tab.linkedBrowser);
+ let finalWindow = tab.ownerGlobal;
+ if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) {
+ // taskFn may resolve within a tick after opening a new tab.
+ // We shouldn't remove the newly opened tab in the same tick.
+ // Wait for the next tick here.
+ await TestUtils.waitForTick();
+ BrowserTestUtils.removeTab(tab);
+ } else {
+ Services.console.logStringMessage(
+ "withFirefoxView: Tab was already closed before " +
+ "removeTab would have been called"
+ );
+ }
+
+ if (shouldCloseWin) {
+ await BrowserTestUtils.closeWindow(win);
+ }
+ return result;
+}
+
+async function openFirefoxViewTab(w) {
+ ok(
+ !w.FirefoxViewHandler.tab,
+ "Firefox View tab doesn't exist prior to clicking the button"
+ );
+ info("Clicking the Firefox View button");
+ await EventUtils.synthesizeMouseAtCenter(
+ w.document.getElementById("firefox-view-button"),
+ { type: "mousedown" },
+ w
+ );
+ assertFirefoxViewTab(w);
+ ok(w.FirefoxViewHandler.tab.selected, "Firefox View tab is selected");
+ await BrowserTestUtils.browserLoaded(w.FirefoxViewHandler.tab.linkedBrowser);
+ return w.FirefoxViewHandler.tab;
+}
+
+function assertFirefoxViewTab(w) {
+ ok(w.FirefoxViewHandler.tab, "Firefox View tab exists");
+ ok(w.FirefoxViewHandler.tab?.hidden, "Firefox View tab is hidden");
+ is(
+ w.gBrowser.visibleTabs.indexOf(w.FirefoxViewHandler.tab),
+ -1,
+ "Firefox View tab is not in the list of visible tabs"
+ );
+}
+
+add_task(async function test_CLICK_ELEMENT() {
+ SpecialPowers.pushPrefEnv([
+ "browser.firefox-view.feature-tour",
+ JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ ]);
+
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.resolves(TEST_MESSAGE);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const calloutSelector = "#root.featureCallout";
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return document.querySelector(
+ `${calloutSelector}:not(.hidden) .${TEST_MESSAGE.message.content.screens[0].id}`
+ );
+ });
+
+ // Clicking the CTA with the CLICK_ELEMENT action should result in the element found with the configured selector being clicked
+ const clickElementSelector =
+ TEST_MESSAGE.message.content.screens[0].content.primary_button.action.data
+ .selector;
+ const clickElement = document.querySelector(clickElementSelector);
+ const successClick = () => {
+ ok(true, "Configured element was clicked");
+ clickElement.removeEventListener("click", successClick);
+ };
+
+ clickElement.addEventListener("click", successClick);
+ document.querySelector(`${calloutSelector} button.primary`).click();
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js
new file mode 100644
index 0000000000..bf1b160706
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+const HOMEPAGE_PREF = "browser.startup.homepage";
+const NEWTAB_PREF = "browser.newtabpage.enabled";
+const HIGHLIGHTS_PREF =
+ "browser.newtabpage.activity-stream.feeds.section.highlights";
+const HIGHLIGHTS_ROWS_PREF =
+ "browser.newtabpage.activity-stream.section.highlights.rows";
+const SEARCH_PREF = "browser.newtabpage.activity-stream.showSearch";
+const TOPSITES_PREF = "browser.newtabpage.activity-stream.feeds.topsites";
+const SNIPPETS_PREF = "browser.newtabpage.activity-stream.feeds.snippets";
+const TOPSTORIES_PREF =
+ "browser.newtabpage.activity-stream.feeds.system.topstories";
+
+add_setup(async function() {
+ await SpecialPowers.pushPrefEnv({
+ // Highlights are preffed off by default.
+ set: [
+ [HIGHLIGHTS_PREF, true],
+ [
+ "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear",
+ "",
+ ],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ [
+ HOMEPAGE_PREF,
+ NEWTAB_PREF,
+ HIGHLIGHTS_PREF,
+ HIGHLIGHTS_ROWS_PREF,
+ SEARCH_PREF,
+ TOPSITES_PREF,
+ SNIPPETS_PREF,
+ ].forEach(prefName => Services.prefs.clearUserPref(prefName));
+ });
+});
+
+add_task(async function test_CONFIGURE_HOMEPAGE_newtab_home_prefs() {
+ const action = {
+ type: "CONFIGURE_HOMEPAGE",
+ data: { homePage: "default", newtab: "default" },
+ };
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [HOMEPAGE_PREF, "about:blank"],
+ [NEWTAB_PREF, false],
+ ],
+ });
+
+ Assert.ok(Services.prefs.prefHasUserValue(HOMEPAGE_PREF), "Test setup ok");
+ Assert.ok(Services.prefs.prefHasUserValue(NEWTAB_PREF), "Test setup ok");
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(HOMEPAGE_PREF),
+ "Homepage pref should be back to default"
+ );
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(NEWTAB_PREF),
+ "Newtab pref should be back to default"
+ );
+});
+
+add_task(async function test_CONFIGURE_HOMEPAGE_layout_prefs() {
+ const action = {
+ type: "CONFIGURE_HOMEPAGE",
+ data: {
+ layout: {
+ search: true,
+ topsites: false,
+ highlights: false,
+ snippets: false,
+ topstories: false,
+ },
+ },
+ };
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [HIGHLIGHTS_ROWS_PREF, 3],
+ [SEARCH_PREF, false],
+ ],
+ });
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(Services.prefs.getBoolPref(SEARCH_PREF), "Search is turned on");
+ Assert.ok(
+ !Services.prefs.getBoolPref(TOPSITES_PREF),
+ "Topsites are turned off"
+ );
+ Assert.ok(
+ Services.prefs.getBoolPref(HIGHLIGHTS_PREF),
+ "HIGHLIGHTS_PREF are on because they have been customized"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref(TOPSTORIES_PREF),
+ "Topstories are turned off"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref(SNIPPETS_PREF),
+ "Snippets are turned off"
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js
new file mode 100644
index 0000000000..2d919456cd
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_set_default_browser() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub();
+
+ await SMATestUtils.executeAndValidateAction(
+ { type: "SET_DEFAULT_BROWSER" },
+ {
+ ownerGlobal: {
+ getShellService: () => ({
+ setAsDefault: stub,
+ }),
+ },
+ }
+ );
+
+ Assert.equal(stub.callCount, 1, "setAsDefault was called by the action");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js
new file mode 100644
index 0000000000..aa61214360
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+const NETWORK_TRR_MODE_PREF = "network.trr.mode";
+
+add_task(async function test_disable_doh() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [DOH_DOORHANGER_DECISION_PREF, "mochitest"],
+ [NETWORK_TRR_MODE_PREF, 0],
+ ],
+ });
+
+ await SMATestUtils.executeAndValidateAction({ type: "DISABLE_DOH" });
+
+ Assert.equal(
+ Services.prefs.getStringPref(DOH_DOORHANGER_DECISION_PREF, ""),
+ "UIDisabled",
+ "Pref should be set on disabled"
+ );
+ Assert.equal(
+ Services.prefs.getIntPref(NETWORK_TRR_MODE_PREF, 0),
+ 5,
+ "Pref should be set on disabled"
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js
new file mode 100644
index 0000000000..cf2ec2c305
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js
@@ -0,0 +1,32 @@
+const TEST_URL =
+ "https://example.com/browser/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/index.md";
+
+function getHeadingsFromDocs(docs) {
+ const re = /### `(\w+)`/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re.exec(docs);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+add_task(async function test_sma_docs() {
+ let request = await fetch(TEST_URL);
+ let docs = await request.text();
+ let headings = getHeadingsFromDocs(docs);
+ const schemaTypes = (
+ await fetchSMASchema
+ ).definitions.SpecialMessageActionSchemas.anyOf.map(
+ s => s.properties.type.enum[0]
+ );
+ for (let schemaType of schemaTypes) {
+ Assert.ok(
+ headings.includes(schemaType),
+ `${schemaType} not found in SpecialMessageActionSchemas/index.md`
+ );
+ }
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_handle_multiaction.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_handle_multiaction.js
new file mode 100644
index 0000000000..709f4515ad
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_handle_multiaction.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_handle_multi_action() {
+ const action = {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "DISABLE_DOH",
+ },
+ {
+ type: "OPEN_AWESOME_BAR",
+ },
+ ],
+ },
+ };
+ const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+ const NETWORK_TRR_MODE_PREF = "network.trr.mode";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [DOH_DOORHANGER_DECISION_PREF, "mochitest"],
+ [NETWORK_TRR_MODE_PREF, 0],
+ ],
+ });
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.equal(
+ Services.prefs.getStringPref(DOH_DOORHANGER_DECISION_PREF, ""),
+ "UIDisabled",
+ "Pref should be set on disabled"
+ );
+ Assert.equal(
+ Services.prefs.getIntPref(NETWORK_TRR_MODE_PREF, 0),
+ 5,
+ "Pref should be set on disabled"
+ );
+
+ Assert.ok(gURLBar.focused, "Focus should be on awesome bar");
+});
+
+add_task(async function test_handle_multi_action_with_invalid_action() {
+ const action = {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "NONSENSE",
+ },
+ ],
+ },
+ };
+
+ await SMATestUtils.validateAction(action);
+
+ let error;
+ try {
+ await SpecialMessageActions.handleAction(action, gBrowser);
+ } catch (e) {
+ error = e;
+ }
+
+ ok(error, "should throw if an unexpected event is handled");
+ Assert.equal(
+ error.message,
+ "Error in MULTI_ACTION event: Special message action with type NONSENSE is unsupported."
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js
new file mode 100644
index 0000000000..264646bd0e
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_ABOUT_PAGE() {
+ const tabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:logins?foo=bar"
+ );
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "logins", entrypoint: "foo=bar", where: "tabshifted" },
+ });
+
+ const tab = await tabPromise;
+ ok(tab, "should open about page with entrypoint in a new tab by default");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_OPEN_ABOUT_PAGE_NEW_WINDOW() {
+ const newWindowPromise = BrowserTestUtils.waitForNewWindow(
+ gBrowser,
+ "about:robots?foo=bar"
+ );
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "robots", entrypoint: "foo=bar", where: "window" },
+ });
+
+ const win = await newWindowPromise;
+ ok(win, "should open about page in a new window");
+ BrowserTestUtils.closeWindow(win);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js
new file mode 100644
index 0000000000..62f7d8bb68
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_AWESOME_BAR() {
+ await SMATestUtils.executeAndValidateAction({ type: "OPEN_AWESOME_BAR" });
+ Assert.ok(gURLBar.focused, "Focus should be on awesome bar");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefox_view.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefox_view.js
new file mode 100644
index 0000000000..b09dcdaa40
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefox_view.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * The setup code and the utility funcitons here are cribbed from (mostly)
+ * browser/components/firefoxview/test/browser/head.js
+ *
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1784979 has been filed to move
+ * these to some place publically accessible, after which we will be able to
+ * a bunch of code from this file.
+ */
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view", true]],
+ });
+
+ CustomizableUI.addWidgetToArea(
+ "firefox-view-button",
+ CustomizableUI.AREA_TABSTRIP,
+ 0
+ );
+
+ registerCleanupFunction(async () => {
+ // If you're running mochitest with --keep-open=true, and need to
+ // easily tell whether the button really appeared, comment out the below
+ // line so that the button hangs around after the test finishes.
+ CustomizableUI.removeWidgetFromArea("firefox-view-button");
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+function assertFirefoxViewTab(w = window) {
+ ok(w.FirefoxViewHandler.tab, "Firefox View tab exists");
+ ok(w.FirefoxViewHandler.tab?.hidden, "Firefox View tab is hidden");
+ is(
+ w.gBrowser.visibleTabs.indexOf(w.FirefoxViewHandler.tab),
+ -1,
+ "Firefox View tab is not in the list of visible tabs"
+ );
+}
+
+function closeFirefoxViewTab(w = window) {
+ w.gBrowser.removeTab(w.FirefoxViewHandler.tab);
+ ok(
+ !w.FirefoxViewHandler.tab,
+ "Reference to Firefox View tab got removed when closing the tab"
+ );
+}
+
+add_task(async function test_open_firefox_view() {
+ // setup
+ let newTabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+
+ // execute
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_FIREFOX_VIEW",
+ });
+
+ // verify
+ await newTabOpened;
+ assertFirefoxViewTab();
+
+ // cleanup
+ closeFirefoxViewTab();
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefoxview_colorways_modal.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefoxview_colorways_modal.js
new file mode 100644
index 0000000000..b812d3601e
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_firefoxview_colorways_modal.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ColorwayClosetOpener } = ChromeUtils.import(
+ "resource:///modules/ColorwayClosetOpener.jsm"
+);
+
+add_task(async function test_open_firefoxview_and_colorways_modal() {
+ const sandbox = sinon.createSandbox();
+ const spy = sandbox.spy(ColorwayClosetOpener, "openModal");
+
+ const tabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:firefoxview"
+ );
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_FIREFOX_VIEW_AND_COLORWAYS_MODAL",
+ });
+
+ const tab = await tabPromise;
+
+ ok(tab, "should open about:firefoxview in a new tab");
+ ok(spy.calledOnce, "ColorwayClosetOpener's openModal was called once");
+
+ BrowserTestUtils.removeTab(tab);
+ sandbox.restore();
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js
new file mode 100644
index 0000000000..b6c933fbcf
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_PRIVATE_BROWSER_WINDOW() {
+ const newWindowPromise = BrowserTestUtils.waitForNewWindow();
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_PRIVATE_BROWSER_WINDOW",
+ });
+ const win = await newWindowPromise;
+ ok(
+ PrivateBrowsingUtils.isWindowPrivate(win),
+ "should open a private browsing window"
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js
new file mode 100644
index 0000000000..c9522426a2
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_PROTECTION_PANEL() {
+ await BrowserTestUtils.withNewTab(EXAMPLE_URL, async browser => {
+ const popupshown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ e => e.target.id == "protections-popup"
+ );
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_PROTECTION_PANEL",
+ });
+
+ let { target: popupEl } = await popupshown;
+ Assert.equal(popupEl.state, "open", "Protections popup is open.");
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js
new file mode 100644
index 0000000000..f9d4fa1252
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_PROTECTION_REPORT() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "about:protections"
+ );
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_PROTECTION_REPORT",
+ });
+
+ await loaded;
+
+ // When the graph is built it means any messaging has finished,
+ // we can close the tab.
+ await SpecialPowers.spawn(browser, [], async function() {
+ await ContentTaskUtils.waitForCondition(() => {
+ let bars = content.document.querySelectorAll(".graph-bar");
+ return bars.length;
+ }, "The graph has been built");
+ });
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_spotlight_dialog.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_spotlight_dialog.js
new file mode 100644
index 0000000000..29e72535ea
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_spotlight_dialog.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { OnboardingMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/OnboardingMessageProvider.jsm"
+);
+
+const { Spotlight } = ChromeUtils.import(
+ "resource://activity-stream/lib/Spotlight.jsm"
+);
+
+add_task(async function test_OPEN_SPOTLIGHT_DIALOG() {
+ let pbNewTabMessage = (
+ await OnboardingMessageProvider.getUntranslatedMessages()
+ ).filter(m => m.id === "PB_NEWTAB_FOCUS_PROMO");
+ info(`Testing ${pbNewTabMessage[0].id}`);
+ let showSpotlightStub = sinon.stub(Spotlight, "showSpotlightDialog");
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_SPOTLIGHT",
+ data: { ...pbNewTabMessage[0].content.promoButton.action.data },
+ });
+
+ Assert.equal(
+ showSpotlightStub.callCount,
+ 1,
+ "Should call showSpotlightDialog"
+ );
+
+ Assert.deepEqual(
+ showSpotlightStub.firstCall.args[1],
+ pbNewTabMessage[0].content.promoButton.action.data,
+ "Should be called with action.data"
+ );
+
+ showSpotlightStub.restore();
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js
new file mode 100644
index 0000000000..876193b7ad
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_URL() {
+ const action = {
+ type: "OPEN_URL",
+ data: { args: EXAMPLE_URL, where: "current" },
+ };
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction(action);
+ const url = await loaded;
+ Assert.equal(
+ url,
+ "https://example.com/",
+ "should open URL in the same tab"
+ );
+ });
+});
+
+add_task(async function test_OPEN_URL_new_tab() {
+ const action = {
+ type: "OPEN_URL",
+ data: { args: EXAMPLE_URL, where: "tab" },
+ };
+ const tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, EXAMPLE_URL);
+ await SpecialMessageActions.handleAction(action, gBrowser);
+ const browser = await tabPromise;
+ ok(browser, "should open URL in a new tab");
+ BrowserTestUtils.removeTab(browser);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js
new file mode 100644
index 0000000000..4425325526
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_PIN_CURRENT_TAB() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await SMATestUtils.executeAndValidateAction({ type: "PIN_CURRENT_TAB" });
+
+ ok(gBrowser.selectedTab.pinned, "should pin current tab");
+
+ gBrowser.unpinTab(gBrowser.selectedTab);
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_firefox.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_firefox.js
new file mode 100644
index 0000000000..09714e6703
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_firefox.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const isWin = AppConstants.platform == "win";
+const isMac = AppConstants.platform == "macosx";
+
+add_task(async function test_PIN_FIREFOX_TO_TASKBAR() {
+ const sandbox = sinon.createSandbox();
+ let shell = {
+ async checkPinCurrentAppToTaskbarAsync() {},
+ QueryInterface: () => shell,
+ get macDockSupport() {
+ return this;
+ },
+ get shellService() {
+ return this;
+ },
+
+ ensureAppIsPinnedToDock: sandbox.stub(),
+ isCurrentAppPinnedToTaskbarAsync: sandbox.stub(),
+ pinCurrentAppToTaskbarAsync: sandbox.stub().resolves(undefined),
+ isAppInDock: false,
+ };
+
+ // Prefer the mocked implementation and fall back to the original version,
+ // which can call back into the mocked version (via this.shellService).
+ shell = new Proxy(shell, {
+ get(target, prop) {
+ return (prop in target ? target : ShellService)[prop];
+ },
+ });
+
+ const test = () =>
+ SMATestUtils.executeAndValidateAction(
+ { type: "PIN_FIREFOX_TO_TASKBAR" },
+ {
+ ownerGlobal: {
+ getShellService: () => shell,
+ },
+ }
+ );
+
+ await test();
+
+ function check(count, message) {
+ Assert.equal(
+ shell.pinCurrentAppToTaskbarAsync.callCount,
+ count * isWin,
+ `pinCurrentAppToTaskbarAsync was ${message} by the action for windows`
+ );
+ Assert.equal(
+ shell.ensureAppIsPinnedToDock.callCount,
+ count * isMac,
+ `ensureAppIsPinnedToDock was ${message} by the action for not windows`
+ );
+ }
+ check(1, "called");
+
+ // Pretend the app is already pinned.
+ shell.isCurrentAppPinnedToTaskbarAsync.resolves(true);
+ shell.isAppInDock = true;
+ await test();
+ check(1, "not called");
+
+ // Pretend the app became unpinned.
+ shell.isCurrentAppPinnedToTaskbarAsync.resolves(false);
+ shell.isAppInDock = false;
+ await test();
+ check(2, "called again");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_private_firefox.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_private_firefox.js
new file mode 100644
index 0000000000..90880edd27
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_private_firefox.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_PIN_PRIVATE_FIREFOX_TO_TASKBAR() {
+ const sandbox = sinon.createSandbox();
+ let shell = {
+ async checkPinCurrentAppToTaskbarAsync() {},
+ QueryInterface: () => shell,
+ get macDockSupport() {
+ return this;
+ },
+ get shellService() {
+ return this;
+ },
+
+ ensureAppIsPinnedToDock: sandbox.stub(),
+ isCurrentAppPinnedToTaskbarAsync: sandbox.stub(),
+ pinCurrentAppToTaskbarAsync: sandbox.stub().resolves(undefined),
+ isAppInDock: false,
+ };
+
+ // Prefer the mocked implementation and fall back to the original version,
+ // which can call back into the mocked version (via this.shellService).
+ shell = new Proxy(shell, {
+ get(target, prop) {
+ return (Object.hasOwn(target, prop) ? target : ShellService)[prop];
+ },
+ });
+
+ const test = () =>
+ SMATestUtils.executeAndValidateAction(
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ data: {
+ privatePin: true,
+ },
+ },
+ {
+ ownerGlobal: {
+ getShellService: () => shell,
+ },
+ }
+ );
+
+ await test();
+
+ function check(count, message, arg) {
+ Assert.equal(
+ shell.pinCurrentAppToTaskbarAsync.callCount,
+ count,
+ `pinCurrentAppToTaskbarAsync was ${message} by the action for windows`
+ );
+ if (arg) {
+ Assert.equal(
+ shell.pinCurrentAppToTaskbarAsync.calledWith(arg),
+ true,
+ `pinCurrentAppToTaskbarAsync was ${message} with the arg: ${JSON.stringify(
+ arg
+ )}`
+ );
+ }
+ }
+ check(1, "called", true);
+
+ // Pretend the app is already pinned.
+ shell.isCurrentAppPinnedToTaskbarAsync.resolves(true);
+ shell.isAppInDock = true;
+ await test();
+ check(1, "not called");
+
+ // Pretend the app became unpinned.
+ shell.isCurrentAppPinnedToTaskbarAsync.resolves(false);
+ shell.isAppInDock = false;
+ await test();
+ check(2, "called again", true);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_set_prefs.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_set_prefs.js
new file mode 100644
index 0000000000..f23c18f5df
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_set_prefs.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const HOMEPAGE_PREF = "browser.startup.homepage";
+const PRIVACY_SEGMENTATION_PREF = "browser.dataFeatureRecommendations.enabled";
+
+const PREFS = [HOMEPAGE_PREF, PRIVACY_SEGMENTATION_PREF];
+
+add_setup(async function() {
+ registerCleanupFunction(async () => {
+ PREFS.forEach(pref => Services.prefs.clearUserPref(pref));
+ });
+});
+
+add_task(async function test_set_privacy_segmentation_pref() {
+ const action = {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PRIVACY_SEGMENTATION_PREF,
+ value: true,
+ },
+ },
+ };
+
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(PRIVACY_SEGMENTATION_PREF),
+ "Test setup ok"
+ );
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(
+ Services.prefs.getBoolPref(PRIVACY_SEGMENTATION_PREF),
+ `${PRIVACY_SEGMENTATION_PREF} pref successfully updated`
+ );
+});
+
+add_task(async function test_clear_privacy_segmentation_pref() {
+ Services.prefs.setBoolPref(PRIVACY_SEGMENTATION_PREF, true);
+ Assert.ok(
+ Services.prefs.prefHasUserValue(PRIVACY_SEGMENTATION_PREF),
+ "Test setup ok"
+ );
+
+ const action = {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PRIVACY_SEGMENTATION_PREF,
+ },
+ },
+ };
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(PRIVACY_SEGMENTATION_PREF),
+ `${PRIVACY_SEGMENTATION_PREF} pref successfully cleared`
+ );
+});
+
+add_task(async function test_set_homepage_pref() {
+ const action = {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: HOMEPAGE_PREF,
+ value: "https://foo.example.com",
+ },
+ },
+ };
+
+ Assert.ok(!Services.prefs.prefHasUserValue(HOMEPAGE_PREF), "Test setup ok");
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.equal(
+ Services.prefs.getStringPref(HOMEPAGE_PREF),
+ "https://foo.example.com",
+ `${HOMEPAGE_PREF} pref successfully updated`
+ );
+});
+
+add_task(async function test_clear_homepage_pref() {
+ Services.prefs.setStringPref(HOMEPAGE_PREF, "https://www.example.com");
+ Assert.ok(Services.prefs.prefHasUserValue(HOMEPAGE_PREF), "Test setup ok");
+
+ const action = {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: HOMEPAGE_PREF,
+ },
+ },
+ };
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(HOMEPAGE_PREF),
+ `${HOMEPAGE_PREF} pref successfully updated`
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js
new file mode 100644
index 0000000000..82bba359a3
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Note: "identity.fxaccounts.remote.root" is set to https://example.com in browser.ini
+add_task(async function test_SHOW_FIREFOX_ACCOUNTS() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "snippets" },
+ });
+ Assert.equal(
+ await loaded,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=snippets&action=email&service=sync",
+ "should load fxa with endpoint=snippets"
+ );
+
+ // Open a URL
+ loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "aboutwelcome" },
+ });
+
+ Assert.equal(
+ await loaded,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=aboutwelcome&action=email&service=sync",
+ "should load fxa with a custom endpoint"
+ );
+
+ // Open a URL with extra parameters
+ loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "test", extraParams: { foo: "bar" } },
+ });
+
+ Assert.equal(
+ await loaded,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=test&action=email&service=sync&foo=bar",
+ "should load fxa with a custom endpoint and extra parameters in url"
+ );
+ });
+
+ add_task(async function test_SHOW_FIREFOX_ACCOUNTS_where() {
+ // Open FXA with a 'where' prop
+ const action = {
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: {
+ entrypoint: "activity-stream-firstrun",
+ where: "tab",
+ },
+ };
+ const tabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=activity-stream-firstrun&action=email&service=sync"
+ );
+
+ await SpecialMessageActions.handleAction(action, gBrowser);
+ const browser = await tabPromise;
+ ok(browser, "should open FXA in a new tab");
+ BrowserTestUtils.removeTab(browser);
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js
new file mode 100644
index 0000000000..306836e6dd
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MigrationUtils } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+
+add_task(async function test_SHOW_MIGRATION_WIZARD() {
+ let migratorOpen = TestUtils.waitForCondition(() => {
+ let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard");
+ return win && win.document && win.document.readyState == "complete";
+ }, "Migrator window loaded");
+
+ SMATestUtils.executeAndValidateAction({ type: "SHOW_MIGRATION_WIZARD" });
+
+ await migratorOpen;
+ let migratorWindow = Services.wm.getMostRecentWindow(
+ "Browser:MigrationWizard"
+ );
+ ok(migratorWindow, "Migrator window opened");
+ await BrowserTestUtils.closeWindow(migratorWindow);
+});
+
+add_task(async function test_SHOW_MIGRATION_WIZARD_WITH_SOURCE() {
+ let migratorOpen = TestUtils.waitForCondition(() => {
+ let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard");
+ return win && win.document && win.document.readyState == "complete";
+ }, "Migrator window loaded");
+
+ SMATestUtils.executeAndValidateAction({
+ type: "SHOW_MIGRATION_WIZARD",
+ data: { source: "chrome" },
+ });
+
+ await migratorOpen;
+ let migratorWindow = Services.wm.getMostRecentWindow(
+ "Browser:MigrationWizard"
+ );
+ ok(migratorWindow, "Migrator window opened when source param specified");
+ await BrowserTestUtils.closeWindow(migratorWindow);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js
new file mode 100644
index 0000000000..53e7b21718
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js
@@ -0,0 +1,64 @@
+/* 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 { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+const { JsonSchema } = ChromeUtils.importESModule(
+ "resource://gre/modules/JsonSchema.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "SpecialMessageActions",
+ "resource://messaging-system/lib/SpecialMessageActions.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "fetchSMASchema", async () => {
+ const response = await fetch(
+ "resource://testing-common/SpecialMessageActionSchemas.json"
+ );
+ const schema = await response.json();
+ if (!schema) {
+ throw new Error("Failed to load SpecialMessageActionSchemas");
+ }
+ return schema;
+});
+
+const EXAMPLE_URL = "https://example.com/";
+
+const SMATestUtils = {
+ /**
+ * Checks if an action is valid acording to existing schemas
+ * @param {SpecialMessageAction} action
+ */
+ async validateAction(action) {
+ const schema = await fetchSMASchema;
+ const result = JsonSchema.validate(action, schema);
+ if (result.errors.length) {
+ throw new Error(
+ `Action with type ${
+ action.type
+ } was not valid. Errors: ${JSON.stringify(result.errors, undefined, 2)}`
+ );
+ }
+ is(
+ result.errors.length,
+ 0,
+ `Should be a valid action of type ${action.type}`
+ );
+ },
+
+ /**
+ * Executes a Special Message Action after validating it
+ * @param {SpecialMessageAction} action
+ * @param {Browser} browser
+ */
+ async executeAndValidateAction(action, browser = gBrowser) {
+ await SMATestUtils.validateAction(action);
+ await SpecialMessageActions.handleAction(action, browser);
+ },
+};
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json
new file mode 100644
index 0000000000..79f1637116
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json
@@ -0,0 +1,261 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$ref": "#/definitions/TriggerActionSchemas",
+ "definitions": {
+ "TriggerActionSchemas": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "openURL"
+ ]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of urls we should match against"
+ },
+ "patterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of Match pattern compatible strings to match against"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user loads a new URL that matches the provided `hosts` or `patterns`"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "openArticleURL"
+ ]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of urls we should match against"
+ },
+ "patterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of Match pattern compatible strings to match against"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user loads a document that is Reader Mode compatible"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "openBookmarkedURL"
+ ]
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user adds a bookmark from the URL bar star icon"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "frequentVisits"
+ ]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of urls we should match against"
+ },
+ "patterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of Match pattern compatible strings to match against"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time a user navigates (or switches tab to) to any of the `hosts` or `patterns` arguments but additionally provides information about the number of accesses to the matched domain."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "newSavedLogin"
+ ]
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user adds or updates a login"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "contentBlocking"
+ ]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": ["string", "integer"],
+ "description": "Events that should trigger this message. String values correspond to ContentBlockingMilestone events and number values correspond to STATE_BLOCKED_* flags on nsIWebProgressListener."
+ }
+ }
+ },
+ "required": [
+ "id",
+ "params"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time Firefox blocks the loading of a page script/asset/resource that matches the one of the tracking behaviours specifid through params. See https://searchfox.org/mozilla-central/rev/8ccea36c4fb09412609fb738c722830d7098602b/uriloader/base/nsIWebProgressListener.idl#336"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["defaultBrowserCheck"]
+ },
+ "context": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "enum": ["newtab"],
+ "description": "When the source of the trigger is home/newtab"
+ },
+ "willShowDefaultPrompt": {
+ "type": "boolean",
+ "description": "When the source of the trigger is startup"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when starting the browser or navigating to about:home/newtab"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["captivePortalLogin"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when the user successfully goes through a captive portal authentication flow."
+ },
+ {
+ "description": "Notify when a preference is added, removed or modified",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["preferenceObserver"]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "Preference names to observe."
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id", "params"]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["featureCalloutCheck"]
+ },
+ "context": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "enum": ["firefoxview"],
+ "description": "Which about page is the source of the trigger"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when navigating to about:firefoxview or other about pages with Feature Callout tours enabled"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["nthTabClosed"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when the user closes n or more tabs in a session"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["activityAfterIdle"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when the user resumes activity after n milliseconds of inactivity (keyboard/mouse interactions and audio playback all count as activity)"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md
new file mode 100644
index 0000000000..1443e7a681
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md
@@ -0,0 +1,162 @@
+# Trigger Listeners
+
+A set of action listeners that can be used to trigger CFR messages.
+
+## Usage
+
+[As part of the CFR definition](https://searchfox.org/mozilla-central/rev/2bfe3415fb3a2fba9b1c694bc0b376365e086927/browser/components/newtab/lib/CFRMessageProvider.jsm#194) the message can register at most one trigger used to decide when the message is shown.
+
+Most triggers (unless otherwise specified) take the same arguments of `hosts` and/or `patterns`
+used to target the message to specific websites.
+
+```javascript
+// Optional set of hosts to filter out triggers only to certain websites
+let params: string[];
+// Optional set of [match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns) to filter out triggers only to certain websites
+let patterns: string[];
+```
+
+```javascript
+{
+ ...
+ // Show the message when opening mozilla.org
+ "trigger": { "id": "openURL", "params": ["mozilla.org", "www.mozilla.org"] }
+ ...
+}
+```
+
+```javascript
+{
+ ...
+ // Show the message when opening any HTTP, HTTPS URL.
+ trigger: { id: "openURL", patterns: ["*://*/*"] }
+ ...
+}
+```
+
+## Available trigger actions
+
+### `openArticleURL`
+
+Happens when the user loads a Reader Mode compatible webpage.
+
+### `openBookmarkedURL`
+
+Happens when the user bookmarks or navigates to a bookmarked URL.
+
+Does not filter by host or patterns.
+
+### `frequentVisits`
+
+Happens every time a user navigates (or switches tab to) to any of the `hosts` or `patterns` arguments
+provided. Additionally it stores timestamps of these visits that are provided back to the targeting context.
+They can be used inside of the targeting expression:
+
+```javascript
+// Has at least 3 visits in the past hour
+recentVisits[.timestamp > (currentDate|date - 3600 * 1000 * 1)]|length >= 3
+
+```
+
+```typescript
+interface visit {
+ host: string,
+ timestamp: UnixTimestamp
+};
+// Host and timestamp for every visit to "Host"
+let recentVisits: visit[];
+```
+
+### `openURL`
+
+Happens every time the user loads a new URL that matches the provided `hosts` or `patterns`.
+During a browsing session it keeps track of visits to unique urls that can be used inside targeting expression.
+
+```javascript
+// True on the third visit for the URL which the trigger matched on
+visitsCount >= 3
+```
+
+### `newSavedLogin`
+
+Happens every time the user saves or updates a login via the login capture doorhanger.
+Provides a `type` to diferentiate between the two events that can be used in targeting.
+
+Does not filter by host or patterns.
+
+```typescript
+let type = "update" | "save";
+```
+
+### `contentBlocking`
+
+Happens at the and of a document load and for every subsequent content blocked event, or when the tracking DB service hits a milestone.
+
+Provides a context of the number of pages loaded in the current browsing session that can be used in targeting.
+
+Does not filter by host or patterns.
+
+The event it reports back is one of two things:
+ * A combination of OR-ed [nsIWebProgressListener](https://searchfox.org/mozilla-central/source/uriloader/base/nsIWebProgressListener.idl) `STATE_BLOCKED_*` flags
+ * A string constants, such as [`"ContentBlockingMilestone"`](https://searchfox.org/mozilla-central/rev/8a2d8d26e25ef70c98c6036612aad534b76b9815/toolkit/components/antitracking/TrackingDBService.jsm#327-334)
+
+
+### `defaultBrowserCheck`
+
+Happens at startup, when opening a newtab and when navigating to about:home.
+At startup it provides the result of running `DefaultBrowserCheck.willCheckDefaultBrowser` to follow existing behaviour if needed.
+On the newtab/homepage it reports the `source` as `newtab`.
+
+```typescript
+let source = "newtab" | undefined;
+let willShowDefaultPrompt = boolean;
+```
+
+### `captivePortalLogin`
+
+Happens when the user successfully goes through a captive portal authentication flow.
+
+### `preferenceObserver`
+
+Watch for changes on any number of preferences. Runs when a pref is added, removed or modified.
+
+```js
+// Register a message with the following trigger
+{
+ id: "preferenceObserver",
+ params: ["pref name"]
+}
+```
+
+### `featureCalloutCheck`
+
+Happens when navigating to about:firefoxview or other about pages with Feature Callout tours enabled
+
+### `nthTabClosed`
+
+Happens when the user closes n or more tabs in a session
+
+```js
+// Register a message with the following trigger and
+// include the tabsClosedCount context variable in the targeting.
+// Here, the message triggers after two or more tabs are closed.
+{
+ trigger: { id: "nthTabClosed" },
+ targeting: "tabsClosedCount >= 2"
+}
+```
+
+### `activityAfterIdle`
+
+Happens when the user resumes activity after n milliseconds of inactivity. Keyboard/mouse interactions and audio playback count as activity. The idle timer is reset when the OS is put to sleep or wakes from sleep.
+
+No params or patterns. The `idleForMilliseconds` context variable is available in targeting. This value represents the number of milliseconds since the last user interaction or audio playback. `60000` is the minimum value for this variable (1 minute). In the following example, the message triggers when the user returns after at least 20 minutes of inactivity.
+
+```js
+// Register a message with the following trigger and include
+// the idleForMilliseconds context variable in the targeting.
+{
+ trigger: { id: "activityAfterIdle" },
+ targeting: "idleForMilliseconds >= 1200000"
+}
+```
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini
new file mode 100644
index 0000000000..51a90285d1
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+support-files =
+ ../../index.md
+
+[browser_asrouter_trigger_listeners.js]
+https_first_disabled = true
+[browser_asrouter_trigger_docs.js]
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js
new file mode 100644
index 0000000000..e2f038eda8
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js
@@ -0,0 +1,74 @@
+const TEST_URL =
+ "https://example.com/browser/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/index.md";
+
+const { ASRouterTriggerListeners } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
+);
+const { CFRMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/CFRMessageProvider.jsm"
+);
+const { JsonSchema } = ChromeUtils.importESModule(
+ "resource://gre/modules/JsonSchema.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "fetchTriggerActionSchema", async () => {
+ const response = await fetch(
+ "resource://testing-common/TriggerActionSchemas.json"
+ );
+ const schema = await response.json();
+ if (!schema) {
+ throw new Error("Failed to load TriggerActionSchemas");
+ }
+ return schema.definitions.TriggerActionSchemas;
+});
+
+async function validateTrigger(trigger) {
+ const schema = await fetchTriggerActionSchema;
+ const result = JsonSchema.validate(trigger, schema);
+ if (result.errors.length) {
+ throw new Error(
+ `Trigger with id ${trigger.id} was not valid. Errors: ${JSON.stringify(
+ result.errors,
+ undefined,
+ 2
+ )}`
+ );
+ }
+ Assert.equal(
+ result.errors.length,
+ 0,
+ `should be a valid trigger of type ${trigger.id}`
+ );
+}
+
+function getHeadingsFromDocs(docs) {
+ const re = /### `(\w+)`/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re.exec(docs);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+add_task(async function test_trigger_docs() {
+ let request = await fetch(TEST_URL, { credentials: "omit" });
+ let docs = await request.text();
+ let headings = getHeadingsFromDocs(docs);
+ for (let triggerName of ASRouterTriggerListeners.keys()) {
+ Assert.ok(
+ headings.includes(triggerName),
+ `${triggerName} not found in TriggerActionSchemas/index.md`
+ );
+ }
+});
+
+add_task(async function test_message_triggers() {
+ const messages = await CFRMessageProvider.getMessages();
+ for (let message of messages) {
+ await validateTrigger(message.trigger);
+ }
+});
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js
new file mode 100644
index 0000000000..12725fb4fb
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js
@@ -0,0 +1,501 @@
+ChromeUtils.defineModuleGetter(
+ this,
+ "ASRouterTriggerListeners",
+ "resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+async function openURLInWindow(window, url) {
+ const { selectedBrowser } = window.gBrowser;
+ BrowserTestUtils.loadURI(selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(selectedBrowser, false, url);
+}
+
+add_task(async function check_matchPatternFailureCase() {
+ const articleTrigger = ASRouterTriggerListeners.get("openArticleURL");
+
+ articleTrigger.uninit();
+
+ articleTrigger.init(() => {}, [], ["example.com"]);
+
+ is(
+ articleTrigger._matchPatternSet.matches("http://example.com"),
+ false,
+ "Should fail, bad pattern"
+ );
+
+ articleTrigger.init(() => {}, [], ["*://*.example.com/"]);
+
+ is(
+ articleTrigger._matchPatternSet.matches("http://www.example.com"),
+ true,
+ "Should work, updated pattern"
+ );
+
+ articleTrigger.uninit();
+});
+
+add_task(async function check_openArticleURL() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+ const articleTrigger = ASRouterTriggerListeners.get("openArticleURL");
+
+ // Previously initialized by the Router
+ articleTrigger.uninit();
+
+ // Initialize the trigger with a new triggerHandler that resolves a promise
+ // with the URL match
+ const listenerTriggered = new Promise(resolve =>
+ articleTrigger.init((browser, match) => resolve(match), ["example.com"])
+ );
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, TEST_URL);
+ // Send a message from the content page (the TEST_URL) to the parent
+ // This should trigger the `receiveMessage` cb in the articleTrigger
+ await ContentTask.spawn(win.gBrowser.selectedBrowser, null, async () => {
+ let readerActor = content.windowGlobalChild.getActor("AboutReader");
+ readerActor.sendAsyncMessage("Reader:UpdateReaderButton", {
+ isArticle: true,
+ });
+ });
+
+ await listenerTriggered.then(data =>
+ is(
+ data.param.url,
+ TEST_URL,
+ "We should match on the TEST_URL as a website article"
+ )
+ );
+
+ // Cleanup
+ articleTrigger.uninit();
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function check_openURL_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let urlVisitCount = 0;
+ const triggerHandler = () => urlVisitCount++;
+ const openURLListener = ASRouterTriggerListeners.get("openURL");
+
+ // Previously initialized by the Router
+ openURLListener.uninit();
+
+ const normalWindow = await BrowserTestUtils.openNewBrowserWindow();
+ const privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // Initialise listener
+ openURLListener.init(triggerHandler, ["example.com"]);
+
+ await openURLInWindow(normalWindow, TEST_URL);
+ await BrowserTestUtils.waitForCondition(
+ () => urlVisitCount !== 0,
+ "Wait for the location change listener to run"
+ );
+ is(urlVisitCount, 1, "should receive page visits from existing windows");
+
+ await openURLInWindow(normalWindow, "http://www.example.com/abc");
+ is(urlVisitCount, 1, "should not receive page visits for different domains");
+
+ await openURLInWindow(privateWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 1,
+ "should not receive page visits from existing private windows"
+ );
+
+ const secondNormalWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(secondNormalWindow, TEST_URL);
+ await BrowserTestUtils.waitForCondition(
+ () => urlVisitCount === 2,
+ "Wait for the location change listener to run"
+ );
+ is(urlVisitCount, 2, "should receive page visits from newly opened windows");
+
+ const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await openURLInWindow(secondPrivateWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 2,
+ "should not receive page visits from newly opened private windows"
+ );
+
+ // Uninitialise listener
+ openURLListener.uninit();
+
+ await openURLInWindow(normalWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 2,
+ "should now not receive page visits from existing windows"
+ );
+
+ const thirdNormalWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(thirdNormalWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 2,
+ "should now not receive page visits from newly opened windows"
+ );
+
+ // Cleanup
+ const windows = [
+ normalWindow,
+ privateWindow,
+ secondNormalWindow,
+ secondPrivateWindow,
+ thirdNormalWindow,
+ ];
+ await Promise.all(windows.map(win => BrowserTestUtils.closeWindow(win)));
+});
+
+add_task(async function check_newSavedLogin_save_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let triggerTypesHandled = {
+ save: 0,
+ update: 0,
+ };
+ const triggerHandler = (sub, { id, context }) => {
+ is(id, "newSavedLogin", "Check trigger id");
+ triggerTypesHandled[context.type]++;
+ };
+ const newSavedLoginListener = ASRouterTriggerListeners.get("newSavedLogin");
+
+ // Previously initialized by the Router
+ newSavedLoginListener.uninit();
+
+ // Initialise listener
+ await newSavedLoginListener.init(triggerHandler);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerNewSavedPassword(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
+ await BrowserTestUtils.waitForCondition(
+ () => triggerTypesHandled.save !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(triggerTypesHandled.save, 1, "should receive observer notification");
+ }
+ );
+
+ is(triggerTypesHandled.update, 0, "shouldn't have handled other trigger");
+
+ // Uninitialise listener
+ newSavedLoginListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerNewSavedPasswordAfterUninit(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
+ await new Promise(resolve => executeSoon(resolve));
+ is(
+ triggerTypesHandled.save,
+ 1,
+ "shouldn't receive obs. notification after uninit"
+ );
+ }
+ );
+});
+
+add_task(async function check_newSavedLogin_update_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let triggerTypesHandled = {
+ save: 0,
+ update: 0,
+ };
+ const triggerHandler = (sub, { id, context }) => {
+ is(id, "newSavedLogin", "Check trigger id");
+ triggerTypesHandled[context.type]++;
+ };
+ const newSavedLoginListener = ASRouterTriggerListeners.get("newSavedLogin");
+
+ // Previously initialized by the Router
+ newSavedLoginListener.uninit();
+
+ // Initialise listener
+ await newSavedLoginListener.init(triggerHandler);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerLoginUpdateSaved(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved");
+ await BrowserTestUtils.waitForCondition(
+ () => triggerTypesHandled.update !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(triggerTypesHandled.update, 1, "should receive observer notification");
+ }
+ );
+
+ is(triggerTypesHandled.save, 0, "shouldn't have handled other trigger");
+
+ // Uninitialise listener
+ newSavedLoginListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerLoginUpdateSavedAfterUninit(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved");
+ await new Promise(resolve => executeSoon(resolve));
+ is(
+ triggerTypesHandled.update,
+ 1,
+ "shouldn't receive obs. notification after uninit"
+ );
+ }
+ );
+});
+
+add_task(async function check_contentBlocking_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ const event1 = 0x0001;
+ const event2 = 0x0010;
+ const event3 = 0x0100;
+ const event4 = 0x1000;
+
+ // Initialise listener to listen 2 events, for any incoming event e,
+ // it will be triggered if and only if:
+ // 1. (e & event1) && (e & event2)
+ // 2. (e & event3)
+ const bindEvents = [event1 | event2, event3];
+
+ let observerEvent = 0;
+ let pageLoadSum = 0;
+ const triggerHandler = (target, trigger) => {
+ const {
+ id,
+ param: { host, type },
+ context: { pageLoad },
+ } = trigger;
+ is(id, "contentBlocking", "should match event name");
+ is(host, TEST_URL, "should match test URL");
+ is(
+ bindEvents.filter(e => (type & e) === e).length,
+ 1,
+ `event ${type} is valid`
+ );
+ ok(pageLoadSum <= pageLoad, "pageLoad is non-decreasing");
+
+ observerEvent += 1;
+ pageLoadSum = pageLoad;
+ };
+ const contentBlockingListener = ASRouterTriggerListeners.get(
+ "contentBlocking"
+ );
+
+ // Previously initialized by the Router
+ contentBlockingListener.uninit();
+
+ await contentBlockingListener.init(triggerHandler, bindEvents);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event1, // won't trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+ }
+ );
+
+ is(observerEvent, 0, "shouldn't receive unrelated observer notification");
+ is(pageLoadSum, 0, "shouldn't receive unrelated observer notification");
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event3, // will trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => observerEvent !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(observerEvent, 1, "should receive observer notification");
+ is(pageLoadSum, 2, "should receive observer notification");
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event1 | event2 | event4, // still trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => observerEvent !== 1,
+ "Wait for the observer notification to run"
+ );
+ is(observerEvent, 2, "should receive another observer notification");
+ is(pageLoadSum, 2, "should receive another observer notification");
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event1, // no trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+
+ await new Promise(resolve => executeSoon(resolve));
+ is(observerEvent, 2, "shouldn't receive unrelated notification");
+ is(pageLoadSum, 2, "shouldn't receive unrelated notification");
+ }
+ );
+
+ // Uninitialise listener
+ contentBlockingListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlockingAfterUninit(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event3, // wont trigger after uninit
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+ await new Promise(resolve => executeSoon(resolve));
+ is(observerEvent, 2, "shouldn't receive obs. notification after uninit");
+ is(pageLoadSum, 2, "shouldn't receive obs. notification after uninit");
+ }
+ );
+});
+
+add_task(async function check_contentBlockingMilestone_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let observerEvent = 0;
+ const triggerHandler = (target, trigger) => {
+ const {
+ id,
+ param: { type },
+ } = trigger;
+ is(id, "contentBlocking", "should match event name");
+ is(type, "ContentBlockingMilestone", "Should be the correct event type");
+ observerEvent += 1;
+ };
+ const contentBlockingListener = ASRouterTriggerListeners.get(
+ "contentBlocking"
+ );
+
+ // Previously initialized by the Router
+ contentBlockingListener.uninit();
+
+ // Initialise listener
+ contentBlockingListener.init(triggerHandler, ["ContentBlockingMilestone"]);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ event: "Other Event",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+ }
+ );
+
+ is(observerEvent, 0, "shouldn't receive unrelated observer notification");
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ event: "ContentBlockingMilestone",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => observerEvent !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(observerEvent, 1, "should receive observer notification");
+ }
+ );
+
+ // Uninitialise listener
+ contentBlockingListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlockingAfterUninit(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ event: "ContentBlockingMilestone",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+ await new Promise(resolve => executeSoon(resolve));
+ is(observerEvent, 1, "shouldn't receive obs. notification after uninit");
+ }
+ );
+});
+
+add_task(function test_pattern_match() {
+ const openURLListener = ASRouterTriggerListeners.get("openURL");
+ openURLListener.uninit();
+ openURLListener.init(() => {}, [], ["*://*/*.pdf"]);
+ let pattern = openURLListener._matchPatternSet;
+
+ Assert.ok(pattern.matches("https://example.com/foo.pdf"), "match 1");
+ Assert.ok(pattern.matches("https://example.com/bar/foo.pdf"), "match 2");
+ Assert.ok(pattern.matches("https://www.example.com/foo.pdf"), "match 3");
+ // Shouldn't match. Too generic.
+ Assert.ok(!pattern.matches("https://www.example.com/foo"), "match 4");
+ Assert.ok(!pattern.matches("https://www.example.com/pdf"), "match 5");
+});
diff --git a/toolkit/components/messaging-system/schemas/index.rst b/toolkit/components/messaging-system/schemas/index.rst
new file mode 100644
index 0000000000..c110d9fc73
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/index.rst
@@ -0,0 +1,187 @@
+Messaging System Schemas
+========================
+
+Docs
+----
+
+More information about `Messaging System`__.
+
+.. __: /browser/components/newtab/content-src/asrouter/docs
+
+Messages
+--------
+
+There are JSON schemas for each type of message that the Firefox Messaging
+System handles:
+
+* `CFR URLBar Chiclet <cfr_urlbar_chiclet_schema_>`_
+* `Extension Doorhanger <extension_doorhanger_schema_>`_
+* `Infobar <infobar_schema_>`_
+* `Spotlight <spotlight_schema_>`_
+* `Toast Notification <toast_notification_schema_>`_
+* `Toolbar Badge <toolbar_badge_schema_>`_
+* `Update Action <update_action_schema_>`_
+* `Whats New <whats_new_schema_>`_
+* `Private Browsing Newtab Promo Message <pbnewtab_promo_schema_>`_
+* `Protections Panel <protections_panel_schema_>`_
+
+Together, they are combined into the `Messaging Experiments
+<messaging_experiments_schema_>`_ via a `script <make_schemas_script_>`_. This
+is the schema used for Nimbus experiments that target messaging features. All
+incoming messaging experiments will be validated against this schema.
+
+Schema Changes
+--------------
+
+To add a new message type to the Messaging Experiments schema:
+
+1. Add your message template schema.
+
+ Your message template schema only needs to define the following fields at a
+ minimum:
+
+ * ``template``: a string field that defines an identifier for your message.
+ This must be either a ``const`` or ``enum`` field.
+
+ For example, the ``template`` field of Spotlight looks like:
+
+ .. code-block:: json
+
+ { "type": "string", "const": "spotlight" }
+
+ * ``content``: an object field that defines your per-message unique content.
+
+ If your message requires ``targeting``, you must add a targeting field.
+
+ If your message supports triggering, there is a definition you can reference
+ the ``MessageTrigger`` `shared definition <Shared Definitions_>`_.
+
+ The ``groups``, ``frequency``, and ``priority`` fields will automatically be
+ inherited by your message.
+
+2. Ensure the schema has an ``$id`` member. This allows for references (e.g.,
+ ``{ "$ref": "#!/$defs/Foo" }``) to work in the bundled schema. See docs on
+ `bundling JSON schemas <jsonschema_bundling_>`_ for more information.
+
+3. Add the new schema to the list in `make-schemas.py <make_schemas_script_>`_.
+4. Build the new schema by running:
+
+ .. code-block:: shell
+
+ cd browser/components/newtab/content-src/asrouter/schemas/
+ ../../../../../../mach python make-schemas.py
+
+5. Commit the results.
+
+Likewise, if you are modifying a message schema you must rebuild the generated
+schema:
+
+.. code-block:: shell
+
+ cd browser/components/newtab/content-src/asrouter/schemas/
+ ../../../../../../mach python make-schemas.py
+
+If you do not, the `Firefox MS Schemas CI job <make_schemas_check_>`_ will fail.
+
+.. _run_make_schemas:
+
+You can run this locally via:
+
+.. code-block:: shell
+
+ cd browser/components/newtab/content-src/asrouter/schemas/
+ ../../../../../../mach xpcshell extract-test-corpus.js
+ ../../../../../../mach python make-schemas.py --check
+
+This test will re-generate the schema and compare it to
+``MessagingExperiment.schema.json``. If there is a difference, it will fail.
+The test will also validate the list of in-tree messages with the same schema
+validator that Experimenter uses to ensure that our schemas are compatible with
+Experimenter.
+
+Shared Definitions
+------------------
+
+Some definitions are shared across multiple schemas. Instead of copying and
+pasting the definitions between them and then having to manually keep them up to
+date, we keep them in a common schema that contains these defintitions:
+`FxMsCommon.schema.json <common_schema_>`_. Any definition that will be re-used
+across multiple schemas should be added to the common schema, which will have
+its definitions bundled into the generated schema. All references to the common
+schema will be rewritten in the generated schema.
+
+The definitions listed in this file are:
+
+* ``Message``, which defines the common fields present in each FxMS message;
+* ``MessageTrigger``, which defines a method that may trigger the message to be
+ presented to the user;
+* ``localizableText``, for when either a string or a string ID (for translation
+ purposes) can be used; and
+* ``localizedText``, for when a string ID is required.
+
+An example of using the ``localizableText`` definition in a message schema follows:
+
+.. code-block:: json
+
+ {
+ "type": "object",
+ "properties": {
+ "message": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText"
+ "description": "The message as a string or string ID"
+ },
+ }
+ }
+
+
+Schema Tests
+------------
+
+We have in-tree tests (`Test_CFRMessageProvider`_,
+`Test_OnboardingMessageProvider`_, and `Test_PanelTestProvider`_), which
+validate existing messages with the generated schema.
+
+We also have compatibility tests for ensuring that our schemas work in
+`Experimenter`_. `Experimenter`_ uses a different JSON schema validation
+library, which is reused in the `Firefox MS Schemas CI job
+<make_schemas_check_>`_. This test validates a test corpus from
+`CFRMessageProvider`_, `OnboardingMessageProvider`_, and `PanelTestProvider`_
+with the same JSON schema validation library and configuration as Experimenter.
+
+See how to run these tests :ref:`above <run_make_schemas_>`.
+
+
+Triggers and actions
+---------------------
+
+.. toctree::
+ :maxdepth: 2
+
+ SpecialMessageActionSchemas/index
+ TriggerActionSchemas/index
+
+.. _cfr_urlbar_chiclet_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json
+.. _extension_doorhanger_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json
+.. _infobar_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json
+.. _spotlight_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json
+.. _toast_notification_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json
+.. _toolbar_badge_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
+.. _update_action_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json
+.. _whats_new_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
+.. _protections_panel_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json
+.. _pbnewtab_promo_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json
+.. _messaging_experiments_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json
+.. _common_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json
+
+.. _make_schemas_script: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/schemas/make-schemas.py
+.. _jsonschema_bundling: https://json-schema.org/understanding-json-schema/structuring.html#bundling
+.. _make_schemas_check: https://searchfox.org/mozilla-central/source/taskcluster/ci/source-test/python.yml#425-438
+
+.. _Experimenter: https://experimenter.info
+
+.. _CFRMessageProvider: https://searchfox.org/mozilla-central/source/browser/components/newtab/lib/CFRMessageProvider.jsm
+.. _PanelTestProvider: https://searchfox.org/mozilla-central/source/browser/components/newtab/lib/PanelTestProvider.jsm
+.. _OnboardingMessageProvider: https://searchfox.org/mozilla-central/source/browser/components/newtab/lib/OnboardingMessageProvider.jsm
+.. _Test_CFRMessageProvider: https://searchfox.org/mozilla-central/source/browser/components/newtab/test/xpcshell/test_CFMessageProvider.js
+.. _Test_OnboardingMessageProvider: https://searchfox.org/mozilla-central/source/browser/components/newtab/test/xpcshell/test_OnboardingMessageProvider.js
+.. _Test_PanelTestProvider: https://searchfox.org/mozilla-central/source/browser/components/newtab/test/xpcshell/test_PanelTestProvider.js
diff --git a/toolkit/components/messaging-system/targeting/Targeting.jsm b/toolkit/components/messaging-system/targeting/Targeting.jsm
new file mode 100644
index 0000000000..5e830b61b3
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/Targeting.jsm
@@ -0,0 +1,251 @@
+/* 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";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
+ FilterExpressions:
+ "resource://gre/modules/components-utils/FilterExpressions.jsm",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
+ ClientEnvironmentBase:
+ "resource://gre/modules/components-utils/ClientEnvironment.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["TargetingContext"];
+
+const TARGETING_EVENT_CATEGORY = "messaging_experiments";
+const TARGETING_EVENT_METHOD = "targeting";
+const DEFAULT_TIMEOUT = 5000;
+const ERROR_TYPES = {
+ ATTRIBUTE_ERROR: "attribute_error",
+ TIMEOUT: "attribute_timeout",
+};
+
+const TargetingEnvironment = {
+ get locale() {
+ return lazy.ASRouterTargeting.Environment.locale;
+ },
+
+ get localeLanguageCode() {
+ return lazy.ASRouterTargeting.Environment.localeLanguageCode;
+ },
+
+ get region() {
+ return lazy.ASRouterTargeting.Environment.region;
+ },
+
+ get userId() {
+ return lazy.ClientEnvironment.userId;
+ },
+
+ get version() {
+ return AppConstants.MOZ_APP_VERSION_DISPLAY;
+ },
+
+ get channel() {
+ const { settings } = lazy.TelemetryEnvironment.currentEnvironment;
+ return settings.update.channel;
+ },
+
+ get platform() {
+ return AppConstants.platform;
+ },
+
+ get os() {
+ return lazy.ClientEnvironmentBase.os;
+ },
+};
+
+class TargetingContext {
+ #telemetrySource = null;
+
+ constructor(customContext, options = { source: null }) {
+ if (customContext) {
+ this.ctx = new Proxy(customContext, {
+ get: (customCtx, prop) => {
+ if (prop in TargetingEnvironment) {
+ return TargetingEnvironment[prop];
+ }
+ return customCtx[prop];
+ },
+ });
+ } else {
+ this.ctx = TargetingEnvironment;
+ }
+
+ // Used in telemetry to report where the targeting expression is coming from
+ this.#telemetrySource = options.source;
+
+ // Enable event recording
+ Services.telemetry.setEventRecordingEnabled(TARGETING_EVENT_CATEGORY, true);
+ }
+
+ setTelemetrySource(source) {
+ if (source) {
+ this.#telemetrySource = source;
+ }
+ }
+
+ _sendUndesiredEvent(eventData) {
+ if (this.#telemetrySource) {
+ Services.telemetry.recordEvent(
+ TARGETING_EVENT_CATEGORY,
+ TARGETING_EVENT_METHOD,
+ eventData.event,
+ eventData.value,
+ { source: this.#telemetrySource }
+ );
+ } else {
+ Services.telemetry.recordEvent(
+ TARGETING_EVENT_CATEGORY,
+ TARGETING_EVENT_METHOD,
+ eventData.event,
+ eventData.value
+ );
+ }
+ }
+
+ /**
+ * Wrap each property of context[key] with a Proxy that captures errors and
+ * timeouts
+ *
+ * @param {Object.<string, TargetingGetters> | TargetingGetters} context
+ * @param {string} key Namespace value found in `context` param
+ * @returns {TargetingGetters} Wrapped context where getter report errors and timeouts
+ */
+ createContextWithTimeout(context, key = null) {
+ const timeoutDuration = key ? context[key].timeout : context.timeout;
+ const logUndesiredEvent = (event, key, prop) => {
+ const value = key ? `${key}.${prop}` : prop;
+ this._sendUndesiredEvent({ event, value });
+ Cu.reportError(`${event}: ${value}`);
+ };
+
+ return new Proxy(context, {
+ get(target, prop) {
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async (resolve, reject) => {
+ // Create timeout cb to record attribute resolution taking too long.
+ let timeout = lazy.setTimeout(() => {
+ logUndesiredEvent(ERROR_TYPES.TIMEOUT, key, prop);
+ reject(
+ new Error(
+ `${prop} targeting getter timed out after ${timeoutDuration ||
+ DEFAULT_TIMEOUT}ms`
+ )
+ );
+ }, timeoutDuration || DEFAULT_TIMEOUT);
+
+ try {
+ resolve(await (key ? target[key][prop] : target[prop]));
+ } catch (error) {
+ logUndesiredEvent(ERROR_TYPES.ATTRIBUTE_ERROR, key, prop);
+ reject(error);
+ Cu.reportError(error);
+ } finally {
+ lazy.clearTimeout(timeout);
+ }
+ });
+ },
+ });
+ }
+
+ /**
+ * Merge all evaluation contexts and wrap the getters with timeouts
+ *
+ * @param {Object.<string, TargetingGetters>[]} contexts
+ * @returns {Object.<string, TargetingGetters>} Object that follows the pattern of `namespace: getters`
+ */
+ mergeEvaluationContexts(contexts) {
+ let context = {};
+ for (let c of contexts) {
+ for (let envNamespace of Object.keys(c)) {
+ // Take the provided context apart, replace it with a proxy
+ context[envNamespace] = this.createContextWithTimeout(c, envNamespace);
+ }
+ }
+
+ return context;
+ }
+
+ /**
+ * Merge multiple TargetingGetters objects without accidentally evaluating
+ *
+ * @param {TargetingGetters[]} ...contexts
+ * @returns {Proxy<TargetingGetters>}
+ */
+ static combineContexts(...contexts) {
+ return new Proxy(
+ {},
+ {
+ get(target, prop) {
+ for (let context of contexts) {
+ if (prop in context) {
+ return context[prop];
+ }
+ }
+
+ return null;
+ },
+ }
+ );
+ }
+
+ /**
+ * Evaluate JEXL expressions with default `TargetingEnvironment` and custom
+ * provided targeting contexts
+ *
+ * @example
+ * eval(
+ * "ctx.locale == 'en-US' && customCtx.foo == 42",
+ * { customCtx: { foo: 42 } }
+ * ); // true
+ *
+ * @param {string} expression JEXL expression
+ * @param {Object.<string, TargetingGetters>[]} ...contexts Additional custom context
+ * objects where the keys act as namespaces for the different getters
+ *
+ * @returns {promise} Evaluation result
+ */
+ eval(expression, ...contexts) {
+ return lazy.FilterExpressions.eval(
+ expression,
+ this.mergeEvaluationContexts([{ ctx: this.ctx }, ...contexts])
+ );
+ }
+
+ /**
+ * Evaluate JEXL expressions with default provided targeting context
+ *
+ * @example
+ * new TargetingContext({ bar: 42 });
+ * evalWithDefault(
+ * "bar == 42",
+ * ); // true
+ *
+ * @param {string} expression JEXL expression
+ * @returns {promise} Evaluation result
+ */
+ evalWithDefault(expression) {
+ return lazy.FilterExpressions.eval(
+ expression,
+ this.createContextWithTimeout(this.ctx)
+ );
+ }
+}
diff --git a/toolkit/components/messaging-system/targeting/test/unit/head.js b/toolkit/components/messaging-system/targeting/test/unit/head.js
new file mode 100644
index 0000000000..ee9dd68635
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/head.js
@@ -0,0 +1,4 @@
+"use strict";
+// Globals
+
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
diff --git a/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js b/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js
new file mode 100644
index 0000000000..5e8c9b0130
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js
@@ -0,0 +1,327 @@
+const { ClientEnvironment } = ChromeUtils.import(
+ "resource://normandy/lib/ClientEnvironment.jsm"
+);
+const { TargetingContext } = ChromeUtils.import(
+ "resource://messaging-system/targeting/Targeting.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+add_task(async function instance_with_default() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ `ctx.locale == '${Services.locale.appLocaleAsBCP47}'`
+ );
+
+ Assert.ok(res, "Has local context");
+});
+
+add_task(async function instance_with_context() {
+ let targeting = new TargetingContext({ bar: 42 });
+
+ let res = await targeting.eval("ctx.bar == 42");
+
+ Assert.ok(res, "Merge provided context with default");
+});
+
+add_task(async function eval_1_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval("custom1.bar == 42", { custom1: { bar: 42 } });
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_2_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval("custom1.bar == 42 && custom2.foo == 42", {
+ custom1: { bar: 42 },
+ custom2: { foo: 42 },
+ });
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_multiple_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ "custom1.bar == 42 && custom2.foo == 42 && custom3.baz == 42",
+ { custom1: { bar: 42 }, custom2: { foo: 42 } },
+ { custom3: { baz: 42 } }
+ );
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_multiple_context_precedence() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ "custom1.bar == 42 && custom2.foo == 42",
+ { custom1: { bar: 24 }, custom2: { foo: 24 } },
+ { custom1: { bar: 42 }, custom2: { foo: 42 } }
+ );
+
+ Assert.ok(res, "Last provided context overrides previously defined ones.");
+});
+
+add_task(async function eval_evalWithDefault() {
+ let targeting = new TargetingContext({ foo: 42 });
+
+ let res = await targeting.evalWithDefault("foo == 42");
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function log_targeting_error_events() {
+ let ctx = {
+ get foo() {
+ throw new Error("unit test");
+ },
+ };
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+
+ await Assert.rejects(
+ targeting.evalWithDefault("foo == 42", ctx),
+ /unit test/,
+ "Getter should throw"
+ );
+
+ Assert.equal(stub.callCount, 1, "Error event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_error", "Correct error message");
+ Assert.equal(value, "foo", "Correct attribute name");
+});
+
+add_task(async function eval_evalWithDefault_precedence() {
+ let targeting = new TargetingContext({ region: "space" });
+ let res = await targeting.evalWithDefault("region != 'space'");
+
+ Assert.ok(res, "Custom context does not override TargetingEnvironment");
+});
+
+add_task(async function eval_evalWithDefault_combineContexts() {
+ let combinedCtxs = TargetingContext.combineContexts({ foo: 1 }, { foo: 2 });
+ let targeting = new TargetingContext(combinedCtxs);
+ let res = await targeting.evalWithDefault("foo == 1");
+
+ Assert.ok(res, "First match is returned for combineContexts");
+});
+
+add_task(async function log_targeting_error_events_in_namespace() {
+ let ctx = {
+ get foo() {
+ throw new Error("unit test");
+ },
+ };
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+ let catchStub = sinon.stub();
+
+ try {
+ await targeting.eval("ctx.foo == 42");
+ } catch (e) {
+ catchStub();
+ }
+
+ Assert.equal(stub.callCount, 1, "Error event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_error", "Correct error message");
+ Assert.equal(value, "ctx.foo", "Correct attribute name");
+ Assert.ok(catchStub.calledOnce, "eval throws errors");
+});
+
+add_task(async function log_timeout_errors() {
+ let ctx = {
+ timeout: 1,
+ get foo() {
+ return new Promise(() => {});
+ },
+ };
+
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+ let catchStub = sinon.stub();
+
+ try {
+ await targeting.eval("ctx.foo");
+ } catch (e) {
+ catchStub();
+ }
+
+ Assert.equal(catchStub.callCount, 1, "Timeout error throws");
+ Assert.equal(stub.callCount, 1, "Timeout event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_timeout", "Correct error message");
+ Assert.equal(value, "ctx.foo", "Correct attribute name");
+});
+
+add_task(async function test_telemetry_event_timeout() {
+ Services.telemetry.clearEvents();
+ let ctx = {
+ timeout: 1,
+ get foo() {
+ return new Promise(() => {});
+ },
+ };
+ let expectedEvents = [
+ ["messaging_experiments", "targeting", "attribute_timeout", "ctx.foo"],
+ ];
+ let targeting = new TargetingContext(ctx);
+
+ try {
+ await targeting.eval("ctx.foo");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+add_task(async function test_telemetry_event_error() {
+ Services.telemetry.clearEvents();
+ let ctx = {
+ get bar() {
+ throw new Error("unit test");
+ },
+ };
+ let expectedEvents = [
+ ["messaging_experiments", "targeting", "attribute_error", "ctx.bar"],
+ ];
+ let targeting = new TargetingContext(ctx);
+
+ try {
+ await targeting.eval("ctx.bar");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+// Make sure that when using the Normandy-style ClientEnvironment context,
+// `liveTelemetry` works. `liveTelemetry` is a particularly tricky object to
+// proxy, so it's useful to check specifically.
+add_task(async function test_live_telemetry() {
+ let ctx = { env: ClientEnvironment };
+ let targeting = new TargetingContext();
+ // This shouldn't throw.
+ await targeting.eval("env.liveTelemetry.main", ctx);
+});
+
+add_task(async function test_default_targeting() {
+ const targeting = new TargetingContext();
+ const expected_attributes = [
+ "locale",
+ "localeLanguageCode",
+ // "region", // Not available in test, requires network access to determine
+ "userId",
+ "version",
+ "channel",
+ "platform",
+ ];
+
+ for (let attribute of expected_attributes) {
+ let res = await targeting.eval(`ctx.${attribute}`);
+ Assert.ok(res, `[eval] result for ${attribute} should not be null`);
+ }
+
+ for (let attribute of expected_attributes) {
+ let res = await targeting.evalWithDefault(attribute);
+ Assert.ok(
+ res,
+ `[evalWithDefault] result for ${attribute} should not be null`
+ );
+ }
+});
+
+add_task(async function test_targeting_os() {
+ const targeting = new TargetingContext();
+ await TestUtils.waitForCondition(() =>
+ targeting.eval("ctx.os.isWindows || ctx.os.isMac || ctx.os.isLinux")
+ );
+ let res = await targeting.eval(
+ `(ctx.os.isWindows && ctx.os.windowsVersion && ctx.os.windowsBuildNumber) ||
+ (ctx.os.isMac && ctx.os.macVersion && ctx.os.darwinVersion) ||
+ (ctx.os.isLinux && os.darwinVersion == null)
+ `
+ );
+ Assert.ok(res, `Should detect platform version got: ${res}`);
+});
+
+add_task(async function test_targeting_source_constructor() {
+ Services.telemetry.clearEvents();
+ const targeting = new TargetingContext(
+ {
+ foo: true,
+ get bar() {
+ throw new Error("bar");
+ },
+ },
+ { source: "unit_testing" }
+ );
+
+ let res = await targeting.eval("ctx.foo");
+ Assert.ok(res, "Should eval to true");
+
+ let expectedEvents = [
+ [
+ "messaging_experiments",
+ "targeting",
+ "attribute_error",
+ "ctx.bar",
+ { source: "unit_testing" },
+ ],
+ ];
+ try {
+ await targeting.eval("ctx.bar");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+add_task(async function test_targeting_source_override() {
+ Services.telemetry.clearEvents();
+ const targeting = new TargetingContext(
+ {
+ foo: true,
+ get bar() {
+ throw new Error("bar");
+ },
+ },
+ { source: "unit_testing" }
+ );
+
+ let res = await targeting.eval("ctx.foo");
+ Assert.ok(res, "Should eval to true");
+
+ let expectedEvents = [
+ [
+ "messaging_experiments",
+ "targeting",
+ "attribute_error",
+ "bar",
+ { source: "override" },
+ ],
+ ];
+ try {
+ targeting.setTelemetrySource("override");
+ await targeting.evalWithDefault("bar");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
diff --git a/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..3653c7f549
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head.js
+tags = messaging-system
+firefox-appdir = browser
+
+[test_targeting.js]