summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/messaging-system
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
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.sys.mjs18
-rw-r--r--toolkit/components/messaging-system/lib/SpecialMessageActions.sys.mjs560
-rw-r--r--toolkit/components/messaging-system/moz.build24
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json600
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md341
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.toml54
-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_configure_homepage.js103
-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_default_pdf_handler.js110
-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_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.js167
-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.js39
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js62
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json297
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md260
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.toml7
-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.js496
-rw-r--r--toolkit/components/messaging-system/schemas/index.rst186
-rw-r--r--toolkit/components/messaging-system/targeting/Targeting.sys.mjs243
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/head.js6
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/test_targeting.js327
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/xpcshell.toml6
42 files changed, 4660 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..9cdae90290
--- /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.sys.mjs (./targeting/Targeting.sys.mjs)
diff --git a/toolkit/components/messaging-system/lib/Logger.sys.mjs b/toolkit/components/messaging-system/lib/Logger.sys.mjs
new file mode 100644
index 0000000000..507d3eef6c
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/Logger.sys.mjs
@@ -0,0 +1,18 @@
+/* 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/. */
+
+import { ConsoleAPI } from "resource://gre/modules/Console.sys.mjs";
+
+const LOGGING_PREF = "messaging-system.log";
+
+export 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.sys.mjs b/toolkit/components/messaging-system/lib/SpecialMessageActions.sys.mjs
new file mode 100644
index 0000000000..7b5151dc23
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/SpecialMessageActions.sys.mjs
@@ -0,0 +1,560 @@
+/* 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/. */
+
+const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+const NETWORK_TRR_MODE_PREF = "network.trr.mode";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ Spotlight: "resource:///modules/asrouter/Spotlight.sys.mjs",
+ UIState: "resource://services-sync/UIState.sys.mjs",
+ UITour: "resource:///modules/UITour.sys.mjs",
+});
+
+export 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) {
+ console.error(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
+ */
+ async setDefaultBrowser(window) {
+ await window.getShellService().setAsDefault();
+ },
+
+ /**
+ * Set browser as the default PDF handler.
+ *
+ * @param {Window} window Reference to a window object
+ */
+ setDefaultPDFHandler(window, onlyIfKnownBrowser = false) {
+ window.getShellService().setAsDefaultPDFHandler(onlyIfKnownBrowser);
+ },
+
+ /**
+ * 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 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.migrate.content-modal.about-welcome-behavior",
+ "browser.migrate.content-modal.import-all.enabled",
+ "browser.migrate.preferences-entrypoint.enabled",
+ "browser.shopping.experience2023.active",
+ "browser.shopping.experience2023.optedIn",
+ "browser.shopping.experience2023.survey.optedInTime",
+ "browser.shopping.experience2023.survey.hasSeen",
+ "browser.shopping.experience2023.survey.pdpVisits",
+ "browser.startup.homepage",
+ "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt",
+ "browser.privateWindowSeparation.enabled",
+ "browser.firefox-view.feature-tour",
+ "browser.pdfjs.feature-tour",
+ "browser.newtab.feature-tour",
+ "cookiebanners.service.mode",
+ "cookiebanners.service.mode.privateBrowsing",
+ "cookiebanners.service.detectOnly",
+ "messaging-system.askForFeedback",
+ ];
+
+ if (
+ !allowedPrefs.includes(pref.name) &&
+ !pref.name.startsWith("messaging-system-action.")
+ ) {
+ pref.name = `messaging-system-action.${pref.name}`;
+ }
+ // 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.`
+ );
+ }
+ },
+
+ /**
+ * Open an FxA sign-in page and automatically close it once sign-in
+ * completes.
+ *
+ * @param {any=} data
+ * @param {Browser} browser the xul:browser rendering the page
+ * @returns {Promise<boolean>} true if the user signed in, false otherwise
+ */
+ async fxaSignInFlow(data, browser) {
+ if (!(await lazy.FxAccounts.canConnectAccount())) {
+ return false;
+ }
+ const url = await lazy.FxAccounts.config.promiseConnectAccountURI(
+ data?.entrypoint || "activity-stream-firstrun",
+ data?.extraParams || {}
+ );
+
+ let window = browser.ownerGlobal;
+
+ let fxaBrowser = await new Promise(resolve => {
+ window.openLinkIn(url, data?.where || "tab", {
+ private: false,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ csp: null,
+ resolveOnContentBrowserCreated: resolve,
+ forceForeground: true,
+ });
+ });
+
+ let gBrowser = fxaBrowser.getTabBrowser();
+ let fxaTab = gBrowser.getTabForBrowser(fxaBrowser);
+
+ let didSignIn = await new Promise(resolve => {
+ // We're going to be setting up a listener and an observer for this
+ // mechanism.
+ //
+ // 1. An event listener for the TabClose event, to detect if the user
+ // closes the tab before completing sign-in
+ // 2. An nsIObserver that listens for the UIState for FxA to reach
+ // STATUS_SIGNED_IN.
+ //
+ // We want to clean up both the listener and observer when all of this
+ // is done.
+ //
+ // We use an AbortController to make it easier to manage the cleanup.
+ let controller = new AbortController();
+ let { signal } = controller;
+
+ // This nsIObserver will listen for the UIState status to change to
+ // STATUS_SIGNED_IN as our definitive signal that FxA sign-in has
+ // completed. It will then resolve the outer Promise to `true`.
+ let fxaObserver = {
+ QueryInterface: ChromeUtils.generateQI([
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference,
+ ]),
+
+ observe(aSubject, aTopic, aData) {
+ let state = lazy.UIState.get();
+ if (state.status === lazy.UIState.STATUS_SIGNED_IN) {
+ // We completed sign-in, so tear down our listener / observer and resolve
+ // didSignIn to true.
+ controller.abort();
+ resolve(true);
+ }
+ },
+ };
+
+ // The TabClose event listener _can_ accept the AbortController signal,
+ // which will then remove the event listener after controller.abort is
+ // called.
+ fxaTab.addEventListener(
+ "TabClose",
+ () => {
+ // If the TabClose event was fired before the event handler was
+ // removed, this means that the tab was closed and sign-in was
+ // not completed, which means we should resolve didSignIn to false.
+ controller.abort();
+ resolve(false);
+ },
+ { once: true, signal }
+ );
+
+ let window = fxaTab.ownerGlobal;
+ window.addEventListener("unload", () => {
+ // If the hosting window unload event was fired before the event handler
+ // was removed, this means that the window was closed and sign-in was
+ // not completed, which means we should resolve didSignIn to false.
+ controller.abort();
+ resolve(false);
+ });
+
+ Services.obs.addObserver(fxaObserver, lazy.UIState.ON_UPDATE);
+
+ // Unfortunately, nsIObserverService.addObserver does not accept an
+ // AbortController signal as a parameter, so instead we listen for the
+ // abort event on the signal to remove the observer.
+ signal.addEventListener(
+ "abort",
+ () => {
+ Services.obs.removeObserver(fxaObserver, lazy.UIState.ON_UPDATE);
+ },
+ { once: true }
+ );
+ });
+
+ // If the user completed sign-in, we'll close the fxaBrowser tab for
+ // them to bring them back to the about:welcome flow.
+ //
+ // If the sign-in page was loaded in a new window, this will close the
+ // tab for that window. That will close the window as well if it's the
+ // last tab in that window.
+ if (didSignIn && data?.autoClose !== false) {
+ gBrowser.removeTab(fxaTab);
+ }
+
+ return didSignIn;
+ },
+
+ /**
+ * 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.
+ * @returns {Promise<unknown>} Type depends on action type. See cases below.
+ */
+ /* eslint-disable-next-line complexity */
+ 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);
+ await this.setDefaultBrowser(window);
+ break;
+ case "SET_DEFAULT_BROWSER":
+ await this.setDefaultBrowser(window);
+ break;
+ case "SET_DEFAULT_PDF_HANDLER":
+ this.setDefaultPDFHandler(
+ window,
+ action.data?.onlyIfKnownBrowser ?? false
+ );
+ break;
+ case "DECLINE_DEFAULT_PDF_HANDLER":
+ Services.prefs.setBoolPref(
+ "browser.shell.checkDefaultPDF.silencedByUser",
+ true
+ );
+ break;
+ case "CONFIRM_LAUNCH_ON_LOGIN":
+ const { WindowsLaunchOnLogin } = ChromeUtils.importESModule(
+ "resource://gre/modules/WindowsLaunchOnLogin.sys.mjs"
+ );
+ await WindowsLaunchOnLogin.createLaunchOnLoginRegistryKey();
+ 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,
+ (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 "FXA_SIGNIN_FLOW":
+ /** @returns {Promise<boolean>} */
+ return this.fxaSignInFlow(action.data, browser);
+ 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 "RELOAD_BROWSER":
+ browser.reload();
+ break;
+ case "FOCUS_URLBAR":
+ window.gURLBar.focus();
+ window.gURLBar.select();
+ break;
+ }
+ return undefined;
+ },
+};
diff --git a/toolkit/components/messaging-system/moz.build b/toolkit/components/messaging-system/moz.build
new file mode 100644
index 0000000000..c94239dc04
--- /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.toml",
+ "schemas/TriggerActionSchemas/test/browser/browser.toml",
+]
+
+SPHINX_TREES["docs"] = "schemas"
+
+XPCSHELL_TESTS_MANIFESTS += ["targeting/test/unit/xpcshell.toml"]
+
+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..4c9bccf8a6
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json
@@ -0,0 +1,600 @@
+{
+ "$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": {
+ "type": {
+ "type": "string",
+ "enum": ["SET_DEFAULT_PDF_HANDLER"]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "onlyIfKnownBrowser": {
+ "type": "boolean",
+ "description": "Only set Firefox as the default PDF handler if the current PDF handler is a known browser."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Message action to set Firefox as the default PDF handler"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["DECLINE_DEFAULT_PDF_HANDLER"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Message action to decline setting Firefox as the default PDF handler"
+ },
+ {
+ "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"
+ },
+ "topstories": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "search",
+ "topsites",
+ "highlights",
+ "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": ["RELOAD_BROWSER"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Message action that reloads the current browser"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["FOCUS_URLBAR"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Focuses the urlbar in the window the message was displayed in"
+ }
+ ]
+ }
+ }
+}
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..8b431a66fe
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md
@@ -0,0 +1,341 @@
+# User Actions
+
+A subset of actions are available to messages via fields like `action` on buttons for CFRs.
+
+## Usage
+
+For CFRs, you should add the action `type` in `action` and any additional parameters in `data`. For example:
+
+```json
+"action": {
+ "type": "OPEN_PREFERENCES_PAGE",
+ "data": { "category": "sync" },
+}
+```
+
+## 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
+"action": {
+ "type": "OPEN_URL",
+ "data": { "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
+"action": {
+ "type": "OPEN_ABOUT_PAGE",
+ "data": { "args": "privatebrowsing" },
+}
+```
+
+### `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
+"action": {
+ "type": "OPEN_PREFERENCES_PAGE",
+ "data": { "category": "general-cfrfeatures" },
+}
+```
+
+### `SHOW_FIREFOX_ACCOUNTS`
+
+* args: (none)
+
+Opens Firefox accounts sign-up page. Encodes some information that the origin was from snippets by default.
+
+### `FXA_SIGNIN_FLOW`
+
+* args:
+
+```ts
+{
+ // a valid `where` value for `openUILinkIn`. Only `tab` and `window` have been tested, and `tabshifted`
+ // is unlikely to do anything different from `tab`.
+ where?: "tab" | "window" = "tab",
+
+ entrypoint?: string // URL search params string to pass along to FxA. Defaults to "activity-stream-firstrun".
+ extraParams?: object // Extra parameters to pass along to FxA. See FxAccountsConfig.promiseConnectAccountURI.
+}
+```
+
+Opens a Firefox accounts sign-up or sign-in page, and does the work of closing the resulting tab or window once
+sign-in completes. Returns a Promise that resolves to `true` if sign-in succeeded, or to `false` if the sign-in
+window or tab closed before sign-in could be completed.
+
+Encodes some information that the origin was from about:welcome 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.
+
+### `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;
+ }
+}
+```
+
+### `PIN_FIREFOX_TO_TASKBAR`
+
+Action for pinning Firefox to the user's taskbar.
+
+* args: (none)
+
+### `SET_DEFAULT_BROWSER`
+
+Action for setting the default browser to Firefox on the user's system.
+
+- args: (none)
+
+### `SET_DEFAULT_PDF_HANDLER`
+
+Action for setting the default PDF handler to Firefox on the user's system.
+
+Windows only.
+
+- args:
+```ts
+{
+ // Only set Firefox as the default PDF handler if the current PDF handler is a
+ // known browser.
+ onlyIfKnownBrowser?: boolean;
+}
+```
+
+### `DECLINE_DEFAULT_PDF_HANDLER`
+
+Action for declining to set the default PDF handler to Firefox on the user's
+system. Prevents the user from being asked again about this.
+
+Windows only.
+
+- 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.migrate.content-modal.about-welcome-behavior`
+- `browser.migrate.content-modal.import-all.enabled`
+- `browser.migrate.preferences-entrypoint.enabled`
+- `browser.shopping.experience2023.active`
+- `browser.shopping.experience2023.optedIn`
+- `browser.shopping.experience2023.survey.optedInTime`
+- `browser.shopping.experience2023.survey.hasSeen`
+- `browser.shopping.experience2023.survey.pdpVisits`
+- `browser.startup.homepage`
+- `browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt`
+- `browser.privateWindowSeparation.enabled`
+- `browser.firefox-view.feature-tour`
+- `browser.pdfjs.feature-tour`
+- `browser.newtab.feature-tour`
+- `cookiebanners.service.mode`
+- `cookiebanners.service.mode.privateBrowsing`
+- `cookiebanners.service.detectOnly`
+- `messaging-system.askForFeedback`
+
+Any pref that begins with `messaging-system-action.` is also allowed.
+Alternatively, if the pref is not present in the list above and does not begin
+with `messaging-system-action.`, it will be created and prepended with
+`messaging-system-action.`. For example, `example.pref` will be created as
+`messaging-system-action.example.pref`.
+
+* 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
+"action": {
+ "type": "MULTI_ACTION",
+ "data": {
+ "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
+
+
+### `RELOAD_BROWSER`
+
+* args: (none)
+
+Action for reloading the current browser.
+
+
+### `FOCUS_URLBAR`
+
+Focuses the urlbar in the window the message was displayed in
+
+* args: (none)
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.toml b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.toml
new file mode 100644
index 0000000000..fc5b6b6550
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.toml
@@ -0,0 +1,54 @@
+[DEFAULT]
+prefs = ["identity.fxaccounts.remote.root=https://example.com/"]
+support-files = [
+ "head.js",
+ "../../index.md",
+]
+
+["browser_sma.js"]
+
+["browser_sma_accept_doh.js"]
+
+["browser_sma_block_message.js"]
+
+["browser_sma_cfrmessageprovider.js"]
+
+["browser_sma_configure_homepage.js"]
+
+["browser_sma_default_browser.js"]
+
+["browser_sma_default_pdf_handler.js"]
+
+["browser_sma_disable_doh.js"]
+
+["browser_sma_docs.js"]
+
+["browser_sma_handle_multiaction.js"]
+
+["browser_sma_open_about_page.js"]
+
+["browser_sma_open_awesome_bar.js"]
+
+["browser_sma_open_private_browser_window.js"]
+
+["browser_sma_open_protection_panel.js"]
+
+["browser_sma_open_protection_report.js"]
+
+["browser_sma_open_spotlight_dialog.js"]
+
+["browser_sma_open_url.js"]
+
+["browser_sma_pin_current_tab.js"]
+
+["browser_sma_pin_firefox.js"]
+
+["browser_sma_pin_private_firefox.js"]
+skip-if = ["os != 'win'"]
+
+["browser_sma_set_prefs.js"]
+
+["browser_sma_show_firefox_accounts.js"]
+
+["browser_sma_show_migration_wizard.js"]
+skip-if = ["apple_catalina && debug"] # Bug 1837646
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..a01b8c8bbf
--- /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.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+
+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_configure_homepage.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js
new file mode 100644
index 0000000000..d05ffb7b1f
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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 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,
+ ].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,
+ 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"
+ );
+});
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_default_pdf_handler.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_pdf_handler.js
new file mode 100644
index 0000000000..30c2229380
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_pdf_handler.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_set_default_pdf_handler_no_data() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub();
+
+ await SMATestUtils.executeAndValidateAction(
+ { type: "SET_DEFAULT_PDF_HANDLER" },
+ {
+ ownerGlobal: {
+ getShellService: () => ({
+ setAsDefaultPDFHandler: stub,
+ }),
+ },
+ }
+ );
+
+ Assert.equal(
+ stub.callCount,
+ 1,
+ "setAsDefaultPDFHandler was called by the action"
+ );
+ Assert.ok(
+ stub.calledWithExactly(false),
+ "setAsDefaultPDFHandler called with onlyIfKnownBrowser = false"
+ );
+});
+
+add_task(async function test_set_default_pdf_handler_data_false() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub();
+
+ await SMATestUtils.executeAndValidateAction(
+ {
+ type: "SET_DEFAULT_PDF_HANDLER",
+ data: {
+ onlyIfKnownBrowser: false,
+ },
+ },
+ {
+ ownerGlobal: {
+ getShellService: () => ({
+ setAsDefaultPDFHandler: stub,
+ }),
+ },
+ }
+ );
+
+ Assert.equal(
+ stub.callCount,
+ 1,
+ "setAsDefaultPDFHandler was called by the action"
+ );
+ Assert.ok(
+ stub.calledWithExactly(false),
+ "setAsDefaultPDFHandler called with onlyIfKnownBrowser = false"
+ );
+});
+
+add_task(async function test_set_default_pdf_handler_data_true() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub();
+
+ await SMATestUtils.executeAndValidateAction(
+ {
+ type: "SET_DEFAULT_PDF_HANDLER",
+ data: {
+ onlyIfKnownBrowser: true,
+ },
+ },
+ {
+ ownerGlobal: {
+ getShellService: () => ({
+ setAsDefaultPDFHandler: stub,
+ }),
+ },
+ }
+ );
+
+ Assert.equal(
+ stub.callCount,
+ 1,
+ "setAsDefaultPDFHandler was called by the action"
+ );
+ Assert.ok(
+ stub.calledWithExactly(true),
+ "setAsDefaultPDFHandler called with onlyIfKnownBrowser = true"
+ );
+});
+
+add_task(async function test_decline_default_pdf_handler() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "browser.shell.checkDefaultPDF.silencedByUser"
+ );
+ });
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "DECLINE_DEFAULT_PDF_HANDLER",
+ });
+
+ Assert.equal(
+ Services.prefs.getBoolPref("browser.shell.checkDefaultPDF.silencedByUser"),
+ true,
+ "DECLINE_DEFAULT_PDF_HANDLER ought to set pref properly."
+ );
+});
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_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..3960075cd9
--- /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..f3274c8aa5
--- /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.importESModule(
+ "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs"
+);
+
+const { Spotlight } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/Spotlight.sys.mjs"
+);
+
+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..3d408192b6
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_set_prefs.js
@@ -0,0 +1,167 @@
+/* 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 MESSAGING_ACTION_PREF = "special-message-testpref";
+
+const PREFS_TO_CLEAR = [
+ HOMEPAGE_PREF,
+ PRIVACY_SEGMENTATION_PREF,
+ `messaging-system-action.${MESSAGING_ACTION_PREF}`,
+];
+
+add_setup(async function () {
+ registerCleanupFunction(async () => {
+ PREFS_TO_CLEAR.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`
+ );
+});
+
+// Set a pref not listed in "allowed prefs"
+add_task(async function test_set_messaging_system_pref() {
+ const action = {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: MESSAGING_ACTION_PREF,
+ value: true,
+ },
+ },
+ };
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.equal(
+ Services.prefs.getBoolPref(
+ `messaging-system-action.${MESSAGING_ACTION_PREF}`
+ ),
+ true,
+ `messaging-system-action.${MESSAGING_ACTION_PREF} pref successfully updated to correct value`
+ );
+});
+
+// Clear a pref not listed in "allowed prefs" that was initially set by
+// the SET_PREF special messaging action
+add_task(async function test_clear_messaging_system_pref() {
+ Services.prefs.setBoolPref(
+ `messaging-system-action.${MESSAGING_ACTION_PREF}`,
+ true
+ );
+ Assert.ok(
+ Services.prefs.prefHasUserValue(
+ `messaging-system-action.${MESSAGING_ACTION_PREF}`
+ ),
+ "Test setup ok"
+ );
+
+ const action = {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: MESSAGING_ACTION_PREF,
+ },
+ },
+ };
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(
+ `messaging-system-action.${MESSAGING_ACTION_PREF}`
+ ),
+ `messaging-system-action.${MESSAGING_ACTION_PREF} pref successfully cleared`
+ );
+});
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..4efbabfe3c
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async () => {
+ // Load the initial tab at example.com. This makes it so that if
+ // when loading the migration wizard in about:preferences, we'll
+ // load the about:preferences page in a new tab rather than overtaking
+ // the initial one. This makes cleanup of that opened tab more explicit.
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, "https://example.com");
+ await BrowserTestUtils.browserLoaded(browser);
+});
+
+add_task(async function test_SHOW_MIGRATION_WIZARD() {
+ let wizardOpened = BrowserTestUtils.waitForMigrationWizard(window);
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_MIGRATION_WIZARD",
+ });
+
+ let wizard = await wizardOpened;
+ ok(wizard, "Migration wizard opened");
+ await BrowserTestUtils.removeTab(wizard);
+});
+
+add_task(async function test_SHOW_MIGRATION_WIZARD_WITH_SOURCE() {
+ let wizardOpened = BrowserTestUtils.waitForMigrationWizard(window);
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_MIGRATION_WIZARD",
+ data: { source: "chrome" },
+ });
+
+ let wizard = await wizardOpened;
+ ok(wizard, "Migrator window opened when source param specified");
+ await BrowserTestUtils.removeTab(wizard);
+});
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..7d77a80ee0
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { JsonSchema } = ChromeUtils.importESModule(
+ "resource://gre/modules/JsonSchema.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SpecialMessageActions:
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
+});
+
+ChromeUtils.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..6a7d2328d7
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json
@@ -0,0 +1,297 @@
+{
+ "$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": ["formAutofill"]
+ }
+ },
+ "required": ["id"],
+ "additionalProperties": false,
+ "description": "Happens when the user saves, updates, or uses a credit card or address for form autofill"
+ },
+ {
+ "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"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Used to display Feature Callouts in Firefox View. Can only be used for Feature Callouts."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["pdfJsFeatureCalloutCheck"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Used to display Feature Callouts on PDF.js pages. Can only be used for Feature Callouts."
+ },
+ {
+ "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)"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["cookieBannerDetected"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when Firefox detects a cookie consent banner that could otherwise be handled by Cookie Banner Handling"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["cookieBannerHandled"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when Firefox automatically engages with a cookie consent banner"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["messagesLoaded"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens as soon as a message is loaded"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["pageActionInUrlbar"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when a page action appears in the urlbar. The specific page action(s) to watch can be specified by id in the targeting expression."
+ }
+ ]
+ }
+ }
+}
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..0d2f6dc89b
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md
@@ -0,0 +1,260 @@
+# 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](#openarticleurl)
+* [openBookmarkedURL](#openbookmarkedurl)
+* [frequentVisits](#frequentvisits)
+* [openURL](#openurl)
+* [newSavedLogin](#newsavedlogin)
+* [formAutofill](#formautofill)
+* [contentBlocking](#contentblocking)
+* [defaultBrowserCheck](#defaultbrowsercheck)
+* [captivePortalLogin](#captiveportallogin)
+* [preferenceObserver](#preferenceobserver)
+* [featureCalloutCheck](#featurecalloutcheck)
+* [nthTabClosed](#nthtabclosed)
+* [activityAfterIdle](#activityafteridle)
+* [cookieBannerDetected](#cookiebannerdetected)
+* [cookieBannerHandled](#cookiebannerhandled)
+* [messagesLoaded](#messagesloaded)
+
+### `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";
+```
+
+### `formAutofill`
+
+Happens when the user saves, updates, or uses a credit card or address for form
+autofill. To reduce the trigger's disruptiveness, it does not fire when the user
+is manually editing these items in the manager in about:preferences. For the
+same reason, the trigger only fires after a 10-second delay. The trigger context
+includes an `event` and `type` that can be used in targeting. Possible events
+include `add`, `update`, and `use`. Possible types are `card` and `address`.
+This trigger is especially intended to be used in tandem with the
+`creditCardsSaved` and `addressesSaved` [targeting attributes](../../../../../browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md).
+
+```js
+{
+ trigger: { id: "formAutofill" },
+ targeting: "type == 'card' && event in ['add', 'update']"
+}
+```
+
+### `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 reports the `source` as `startup`, and it provides a context
+attribute `willShowDefaultPrompt` that can be used in targeting to avoid showing
+a message when the built-in default browser prompt is going to be displayed.
+This is important to avoid the negative UX of showing two promts back-to-back,
+especially if both prompts offer similar affordances.
+On the newtab/homepage, it reports the `source` as `newtab`.
+
+```ts
+let source = "startup" | "newtab";
+let willShowDefaultPrompt = boolean | undefined;
+```
+
+#### Examples
+* Only trigger on startup, not on newtab/homepage
+* Don't show if the built-in prompt is going to be shown
+```js
+{
+ trigger: { id: "defaultBrowserCheck" },
+ targeting: "source == 'startup' && !willShowDefaultPrompt"
+}
+```
+
+### `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`
+
+Used to display Feature Callouts in Firefox View. Can only be used for Feature Callouts.
+
+### `pdfJsFeatureCalloutCheck`
+
+Used to display Feature Callouts on PDF.js pages. Can only be used for Feature Callouts.
+
+### `newtabFeatureCalloutCheck`
+
+Used to display Feature Callouts on about:newtab. Can only be used for Feature Callouts.
+
+### `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"
+}
+```
+
+### `cookieBannerDetected`
+
+Happens when the `cookiebannerdetected` window event is dispatched. This event is dispatched when the following conditions are true:
+
+1. The user is presented with a cookie consent banner on the webpage they're viewing,
+2. The domain has a valid ruleset for automatically engaging with the consent banner, and
+3. The user has not explicitly opted in or out of the Cookie Banner Handling feature.
+
+### `cookieBannerHandled`
+
+Happens when the `cookiebannerhandled` window event is dispatched. This event is dispatched when the following conditions are true:
+
+1. The user is presented with a cookie consent banner on the webpage they're viewing,
+2. The domain has a valid ruleset for automatically engaging with the consent banner, and
+3. The user is opted into the Cookie Banner Handling feature (this is by default in private windows), and
+4. Firefox succeeds in automatically engaging with the consent banner.
+
+### `messagesLoaded`
+
+Happens as soon as a message is loaded. This trigger does not require any user interaction, and may happen potentially as early as app launch, or at some time after experiment enrollment. Generally intended for use in reach experiments, because most messages cannot be routed unless the surfaces they display in are instantiated in a tabbed browser window (a reach message will not be displayed but its trigger will still be recorded). However, it is still possible to safely use this trigger for a normal message, with some caveats. This is potentially relevant on macOS, where the app can be running with no browser windows open, or even on Windows, where closing all browser windows but leaving open a non-browser window (e.g. the Library) causes the app to remain running.
+
+A `toast_notification` or `update_action` message can function normally under these circumstances. A `toolbar_badge` message will load with or without a window, but will not actually display until a window exists. But messages with templates like `infobar` will have no effect unless a window exists to display them in. Any message using this trigger, regardless of template, can exclude window-less or browser-less contexts by adding the following targeting. This isn't strictly necessary because the messaging surfaces will either work normally or fail gracefully, but it may be desirable to test reach only in certain contexts, so the context objects `browser` and `browserWindow` are provided, corresponding to the selected browser (`gBrowser.selectedBrowser`) and the most recently active chrome window, respectively.
+
+```js
+{
+ trigger: { id: "messagesLoaded" },
+ targeting: "browser && browserWindow"
+}
+```
+
+### `pageActionInUrlbar`
+
+Happens when a page action appears in the location bar. The specific page action(s) to watch for can be specified by id in the targeting expression. For example, to trigger when the reader mode button appears:
+
+```js
+{
+ trigger: { id: "pageActionInUrlbar" },
+ targeting: "pageAction == 'reader-mode-button'"
+}
+```
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.toml b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.toml
new file mode 100644
index 0000000000..18df0a89d8
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.toml
@@ -0,0 +1,7 @@
+[DEFAULT]
+support-files = ["../../index.md"]
+
+["browser_asrouter_trigger_docs.js"]
+
+["browser_asrouter_trigger_listeners.js"]
+https_first_disabled = true
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..e886d06bfe
--- /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.importESModule(
+ "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+const { JsonSchema } = ChromeUtils.importESModule(
+ "resource://gre/modules/JsonSchema.sys.mjs"
+);
+
+ChromeUtils.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..816c42775b
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js
@@ -0,0 +1,496 @@
+ChromeUtils.defineESModuleGetters(this, {
+ ASRouterTriggerListeners:
+ "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+async function openURLInWindow(window, url) {
+ const { selectedBrowser } = window.gBrowser;
+ BrowserTestUtils.startLoadingURIString(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`
+ );
+ Assert.lessOrEqual(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..f4db543600
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/index.rst
@@ -0,0 +1,186 @@
+Messaging System Schemas
+========================
+
+Docs
+----
+
+More information about `Messaging System`__.
+
+.. __: /browser/components/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_>`_
+
+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/asrouter/content-src/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/asrouter/content-src/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/asrouter/content-src/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/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json
+.. _extension_doorhanger_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json
+.. _infobar_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json
+.. _spotlight_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json
+.. _toast_notification_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json
+.. _toolbar_badge_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
+.. _update_action_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json
+.. _whats_new_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json
+.. _protections_panel_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json
+.. _pbnewtab_promo_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json
+.. _messaging_experiments_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json
+.. _common_schema: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json
+
+.. _make_schemas_script: https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/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/asrouter/modules/CFRMessageProvider.jsm
+.. _PanelTestProvider: https://searchfox.org/mozilla-central/source/browser/components/asrouter/modules/PanelTestProvider.jsm
+.. _OnboardingMessageProvider: https://searchfox.org/mozilla-central/source/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs
+.. _Test_CFRMessageProvider: https://searchfox.org/mozilla-central/source/browser/components/asrouter/tests//xpcshell/test_CFMessageProvider.js
+.. _Test_OnboardingMessageProvider: https://searchfox.org/mozilla-central/source/browser/components/asrouter/tests//xpcshell/test_OnboardingMessageProvider.js
+.. _Test_PanelTestProvider: https://searchfox.org/mozilla-central/source/browser/components/asrouter/tests//xpcshell/test_PanelTestProvider.js
diff --git a/toolkit/components/messaging-system/targeting/Targeting.sys.mjs b/toolkit/components/messaging-system/targeting/Targeting.sys.mjs
new file mode 100644
index 0000000000..8134920b18
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/Targeting.sys.mjs
@@ -0,0 +1,243 @@
+/* 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ASRouterTargeting:
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ ClientEnvironmentBase:
+ "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
+ FilterExpressions:
+ "resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+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;
+ },
+};
+
+export 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 });
+ console.error(`${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);
+ console.error(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..25ed4b243c
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/head.js
@@ -0,0 +1,6 @@
+"use strict";
+// Globals
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
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..7d13e33751
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js
@@ -0,0 +1,327 @@
+const { ClientEnvironment } = ChromeUtils.importESModule(
+ "resource://normandy/lib/ClientEnvironment.sys.mjs"
+);
+const { TargetingContext } = ChromeUtils.importESModule(
+ "resource://messaging-system/targeting/Targeting.sys.mjs"
+);
+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.toml b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..023bab422b
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.toml
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = "head.js"
+tags = "messaging-system"
+firefox-appdir = "browser"
+
+["test_targeting.js"]