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