summaryrefslogtreecommitdiffstats
path: root/browser/components/uitour
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/uitour')
-rw-r--r--browser/components/uitour/UITour-lib.js829
-rw-r--r--browser/components/uitour/UITour.sys.mjs2044
-rw-r--r--browser/components/uitour/UITourChild.sys.mjs124
-rw-r--r--browser/components/uitour/UITourParent.sys.mjs18
-rw-r--r--browser/components/uitour/docs/UITour-lib.rst11
-rw-r--r--browser/components/uitour/docs/index.rst8
-rw-r--r--browser/components/uitour/moz.build14
-rw-r--r--browser/components/uitour/test/browser.ini49
-rw-r--r--browser/components/uitour/test/browser_UITour.js751
-rw-r--r--browser/components/uitour/test/browser_UITour2.js150
-rw-r--r--browser/components/uitour/test/browser_UITour3.js317
-rw-r--r--browser/components/uitour/test/browser_UITour4.js235
-rw-r--r--browser/components/uitour/test/browser_UITour5.js60
-rw-r--r--browser/components/uitour/test/browser_UITour_annotation_size_attributes.js65
-rw-r--r--browser/components/uitour/test/browser_UITour_availableTargets.js129
-rw-r--r--browser/components/uitour/test/browser_UITour_colorway.js74
-rw-r--r--browser/components/uitour/test/browser_UITour_defaultBrowser.js66
-rw-r--r--browser/components/uitour/test/browser_UITour_detach_tab.js113
-rw-r--r--browser/components/uitour/test/browser_UITour_forceReaderMode.js24
-rw-r--r--browser/components/uitour/test/browser_UITour_modalDialog.js116
-rw-r--r--browser/components/uitour/test/browser_UITour_observe.js99
-rw-r--r--browser/components/uitour/test/browser_UITour_panel_close_annotation.js227
-rw-r--r--browser/components/uitour/test/browser_UITour_pocket.js38
-rw-r--r--browser/components/uitour/test/browser_UITour_resetProfile.js46
-rw-r--r--browser/components/uitour/test/browser_UITour_showNewTab.js25
-rw-r--r--browser/components/uitour/test/browser_UITour_showProtectionReport.js47
-rw-r--r--browser/components/uitour/test/browser_UITour_sync.js231
-rw-r--r--browser/components/uitour/test/browser_UITour_toggleReaderMode.js21
-rw-r--r--browser/components/uitour/test/browser_backgroundTab.js57
-rw-r--r--browser/components/uitour/test/browser_closeTab.js23
-rw-r--r--browser/components/uitour/test/browser_fxa.js61
-rw-r--r--browser/components/uitour/test/browser_fxa_config.js379
-rw-r--r--browser/components/uitour/test/browser_openPreferences.js73
-rw-r--r--browser/components/uitour/test/browser_openSearchPanel.js34
-rw-r--r--browser/components/uitour/test/head.js539
-rw-r--r--browser/components/uitour/test/image.pngbin0 -> 56060 bytes
-rw-r--r--browser/components/uitour/test/uitour.html42
37 files changed, 7139 insertions, 0 deletions
diff --git a/browser/components/uitour/UITour-lib.js b/browser/components/uitour/UITour-lib.js
new file mode 100644
index 0000000000..54574cf651
--- /dev/null
+++ b/browser/components/uitour/UITour-lib.js
@@ -0,0 +1,829 @@
+/* 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/. */
+
+/* eslint valid-jsdoc: ["error", { "requireReturn": false }] */
+
+// create namespace
+if (typeof Mozilla == "undefined") {
+ var Mozilla = {};
+}
+
+(function ($) {
+ "use strict";
+
+ // create namespace
+ if (typeof Mozilla.UITour == "undefined") {
+ /**
+ * Library that exposes an event-based Web API for communicating with the
+ * desktop browser chrome. It can be used for tasks such as opening menu
+ * panels and highlighting the position of buttons in the toolbar.
+ *
+ * For security/privacy reasons `Mozilla.UITour` will only work on a list of allowed
+ * secure origins. The list of allowed origins can be found in
+ * https://searchfox.org/mozilla-central/source/browser/app/permissions.
+ *
+ * @since 29
+ * @namespace
+ */
+ Mozilla.UITour = {};
+ }
+
+ function _sendEvent(action, data) {
+ var event = new CustomEvent("mozUITour", {
+ bubbles: true,
+ detail: {
+ action,
+ data: data || {},
+ },
+ });
+
+ document.dispatchEvent(event);
+ }
+
+ function _generateCallbackID() {
+ return Math.random()
+ .toString(36)
+ .replace(/[^a-z]+/g, "");
+ }
+
+ function _waitForCallback(callback) {
+ var id = _generateCallbackID();
+
+ function listener(event) {
+ if (typeof event.detail != "object") {
+ return;
+ }
+ if (event.detail.callbackID != id) {
+ return;
+ }
+
+ document.removeEventListener("mozUITourResponse", listener);
+ callback(event.detail.data);
+ }
+ document.addEventListener("mozUITourResponse", listener);
+
+ return id;
+ }
+
+ var notificationListener = null;
+ function _notificationListener(event) {
+ if (typeof event.detail != "object") {
+ return;
+ }
+ if (typeof notificationListener != "function") {
+ return;
+ }
+
+ notificationListener(event.detail.event, event.detail.params);
+ }
+
+ Mozilla.UITour.DEFAULT_THEME_CYCLE_DELAY = 10 * 1000;
+
+ Mozilla.UITour.CONFIGNAME_SYNC = "sync";
+ Mozilla.UITour.CONFIGNAME_AVAILABLETARGETS = "availableTargets";
+
+ /**
+ * @typedef {String} Mozilla.UITour.Target
+ *
+ * @summary Not all targets are available at all times because they may not be visible
+ * or UITour doesn't not how to automatically make them visible. Use the
+ * following to determine which ones are available at a given time::
+ *
+ * .. code-block:: javascript
+ *
+ * Mozilla.UITour.getConfiguration('availableTargets', callback)
+ *
+ * @see Mozilla.UITour.getConfiguration
+ * @see Mozilla.UITour.showHighlight
+ * @see Mozilla.UITour.showInfo
+ *
+ * @description Valid values:
+ *
+ * - accountStatus
+ * - addons
+ * - appMenu
+ * - backForward
+ * - bookmarks
+ * - forget
+ * - help
+ * - home
+ * - logins
+ * - pageAction-bookmark
+ * - pocket
+ * - privateWindow
+ * - quit
+ * - readerMode-urlBar
+ * - search
+ * - searchIcon
+ * - selectedTabIcon
+ * - urlbar
+ *
+ * Generate using the following in the Browser Console:
+ *
+ * .. code-block:: javascript
+ *
+ * [...UITour.targets.keys()].join("\n* - ")
+ *
+ */
+
+ /**
+ * Ensure the browser is ready to handle this document as a tour.
+ *
+ * @param {Function} [callback] Callback to call if UITour is working for the document.
+ * @since 35
+ */
+ Mozilla.UITour.ping = function (callback) {
+ var data = {};
+ if (callback) {
+ data.callbackID = _waitForCallback(callback);
+ }
+ _sendEvent("ping", data);
+ };
+
+ /**
+ * @summary Register a listener to observe all UITour notifications.
+ *
+ * @description There can only be one observer per tour tab so calling this a second time will
+ * replace any previous `listener`.
+ * To remove an observer, call the method with `null` as the first argument.
+ *
+ * @param {?Function} listener - Called when any UITour notification occurs.
+ * @param {Function} [callback] - Called when the browser acknowledges the observer.
+ */
+ Mozilla.UITour.observe = function (listener, callback) {
+ notificationListener = listener;
+
+ if (listener) {
+ document.addEventListener("mozUITourNotification", _notificationListener);
+ Mozilla.UITour.ping(callback);
+ } else {
+ document.removeEventListener(
+ "mozUITourNotification",
+ _notificationListener
+ );
+ }
+ };
+
+ /**
+ * Register an identifier to use in
+ * `Telemetry <https://wiki.mozilla.org/Telemetry>`_. `pageID` must be a
+ * string unique to the page/tour.
+ *
+ * @example
+ * Mozilla.UITour.registerPageID('firstrun-page-firefox-29');
+ *
+ * @param {string} pageID Unique identifier for the page/tour.
+ * @memberof Mozilla.UITour
+ */
+ Mozilla.UITour.registerPageID = function (pageID) {
+ _sendEvent("registerPageID", {
+ pageID,
+ });
+ };
+
+ /**
+ * @typedef {String} Mozilla.UITour.HighlightEffect
+ *
+ * Specifies the effect/animation to use when highlighting UI elements.
+ * @description Valid values:
+ *
+ * - random
+ * - wobble
+ * - zoom
+ * - color
+ *
+ * Generate using the following in the Browser Console:
+ *
+ * .. code-block:: javascript
+ *
+ * [...UITour.highlightEffects].join("\n* - ")
+ *
+ * @see Mozilla.UITour.showHighlight
+ */
+
+ /**
+ * Visually highlight a UI widget.
+ *
+ * @see Mozilla.UITour.hideHighlight
+ * @example Mozilla.UITour.showHighlight('appMenu', 'wobble');
+ *
+ * @param {Mozilla.UITour.Target} target - Identifier of the UI widget to show a highlight on.
+ * @param {Mozilla.UITour.HighlightEffect} [effect="none"] - Name of the effect to use when highlighting.
+ */
+ Mozilla.UITour.showHighlight = function (target, effect) {
+ _sendEvent("showHighlight", {
+ target,
+ effect,
+ });
+ };
+
+ /**
+ * Hide any visible UI highlight.
+ * @see Mozilla.UITour.showHighlight
+ */
+ Mozilla.UITour.hideHighlight = function () {
+ _sendEvent("hideHighlight");
+ };
+
+ /**
+ * Show an arrow panel with optional images and buttons anchored at a specific UI target.
+ *
+ * @see Mozilla.UITour.hideInfo
+ *
+ * @param {Mozilla.UITour.Target} target - Identifier of the UI widget to anchor the panel at.
+ * @param {String} title - Title text to be shown as the heading of the panel.
+ * @param {String} text - Body text of the panel.
+ * @param {String} [icon=null] - URL of a 48x48px (96px @ 2dppx) image (which will be resolved
+ * relative to the tab's URI) to display in the panel.
+ * @param {Object[]} [buttons=[]] - Array of objects describing buttons.
+ * @param {String} buttons[].label - Button label
+ * @param {String} buttons[].icon - Button icon URL
+ * @param {String} buttons[].style - Button style ("primary" or "link")
+ * @param {Function} buttons[].callback - Called when the button is clicked
+ * @param {Object} [options={}] - Advanced options
+ * @param {Function} options.closeButtonCallback - Called when the panel's close button is clicked.
+ *
+ * @example
+ * var buttons = [
+ * {
+ * label: 'Cancel',
+ * style: 'link',
+ * callback: cancelBtnCallback
+ * },
+ * {
+ * label: 'Confirm',
+ * style: 'primary',
+ * callback: confirmBtnCallback
+ * }
+ * ];
+ *
+ * var icon = '//mozorg.cdn.mozilla.net/media/img/firefox/australis/logo.png';
+ *
+ * var options = {
+ * closeButtonCallback: closeBtnCallback
+ * };
+ *
+ * Mozilla.UITour.showInfo('appMenu', 'my title', 'my text', icon, buttons, options);
+ */
+ Mozilla.UITour.showInfo = function (
+ target,
+ title,
+ text,
+ icon,
+ buttons,
+ options
+ ) {
+ var buttonData = [];
+ if (Array.isArray(buttons)) {
+ for (var i = 0; i < buttons.length; i++) {
+ buttonData.push({
+ label: buttons[i].label,
+ icon: buttons[i].icon,
+ style: buttons[i].style,
+ callbackID: _waitForCallback(buttons[i].callback),
+ });
+ }
+ }
+
+ var closeButtonCallbackID, targetCallbackID;
+ if (options && options.closeButtonCallback) {
+ closeButtonCallbackID = _waitForCallback(options.closeButtonCallback);
+ }
+ if (options && options.targetCallback) {
+ targetCallbackID = _waitForCallback(options.targetCallback);
+ }
+
+ _sendEvent("showInfo", {
+ target,
+ title,
+ text,
+ icon,
+ buttons: buttonData,
+ closeButtonCallbackID,
+ targetCallbackID,
+ });
+ };
+
+ /**
+ * Hide any visible info panels.
+ * @see Mozilla.UITour.showInfo
+ */
+ Mozilla.UITour.hideInfo = function () {
+ _sendEvent("hideInfo");
+ };
+
+ /**
+ * @typedef {String} Mozilla.UITour.MenuName
+ * Valid values:
+ *
+ * - appMenu
+ * - bookmarks
+ * - pocket
+ *
+ * @see Mozilla.UITour.showMenu
+ * @see Mozilla.UITour.hideMenu
+ * @see Mozilla.UITour.openSearchPanel
+ */
+
+ /**
+ * Open the named application menu.
+ *
+ * @see Mozilla.UITour.hideMenu
+ *
+ * @param {Mozilla.UITour.MenuName} name - Menu name
+ * @param {Function} [callback] - Callback to be called with no arguments when
+ * the menu opens.
+ *
+ * @example
+ * Mozilla.UITour.showMenu('appMenu', function() {
+ * console.log('menu was opened');
+ * });
+ */
+ Mozilla.UITour.showMenu = function (name, callback) {
+ var showCallbackID;
+ if (callback) {
+ showCallbackID = _waitForCallback(callback);
+ }
+
+ _sendEvent("showMenu", {
+ name,
+ showCallbackID,
+ });
+ };
+
+ /**
+ * Close the named application menu.
+ *
+ * @see Mozilla.UITour.showMenu
+ *
+ * @param {Mozilla.UITour.MenuName} name - Menu name
+ */
+ Mozilla.UITour.hideMenu = function (name) {
+ _sendEvent("hideMenu", {
+ name,
+ });
+ };
+
+ /**
+ * Loads about:newtab in the tour tab.
+ * @since 51
+ */
+ Mozilla.UITour.showNewTab = function () {
+ _sendEvent("showNewTab");
+ };
+
+ /**
+ * Loads about:protections in the tour tab.
+ * @since 70
+ */
+ Mozilla.UITour.showProtectionReport = function () {
+ _sendEvent("showProtectionReport");
+ };
+
+ /**
+ * @typedef Mozilla.UITour.ConfigurationName
+ * @description Valid values:
+ *
+ * - :js:func:`appinfo <Mozilla.UITour.Configuration.AppInfo>`
+ * - :js:func:`canReset <Mozilla.UITour.Configuration.CanReset>`
+ * - :js:func:`availableTargets <Mozilla.UITour.Configuration.AvailableTargets>`
+ * - :js:func:`search <Mozilla.UITour.Configuration.Search>`
+ * - :js:func:`selectedSearchEngine <Mozilla.UITour.Configuration.Search>`
+ * DEPRECATED, use 'search'
+ * - :js:func:`sync <Mozilla.UITour.Configuration.Sync>`
+ * DEPRECATED, use 'fxa'
+ * - :js:func:`fxa <Mozilla.UITour.Configuration.FxA>`
+ * - :js:func:`fxaConnections <Mozilla.UITour.Configuration.FxAConnections>`
+ *
+ */
+
+ /**
+ * @namespace Mozilla.UITour.Configuration
+ * @see Mozilla.UITour.getConfiguration
+ * @see Mozilla.UITour.ConfigurationName
+ */
+
+ /**
+ * @typedef {boolean} Mozilla.UITour.Configuration.CanReset
+ *
+ * @description Indicate whether a user can refresh their Firefox profile via :js:func:`Mozilla.UITour.resetFirefox`.
+ *
+ * @see Mozilla.UITour.resetFirefox
+ * @since 48
+ */
+
+ /**
+ * @typedef {object} Mozilla.UITour.Configuration.AppInfo
+ * @property {Boolean} canSetDefaultBrowserInBackground - Whether the application can be set as
+ * the default browser in the background
+ * without user interaction.
+ * @property {Boolean} defaultBrowser - Whether the application is the default browser. Since Fx40.
+ * @property {String} defaultUpdateChannel - Update channel e.g. 'release', 'beta', 'aurora',
+ * 'nightly', 'default'
+ * (self-built or automated testing builds)
+ * @property {String} distribution - Contains the distributionId property. This value will be
+ * "default" in most cases but can differ for repack or
+ * funnelcake builds. Since Fx48
+ * @property {Number} profileCreatedWeeksAgo - The number of weeks since the profile was created,
+ * starting from 0 for profiles dating less than
+ * seven days old. Since Fx56.
+ * @property {Number} profileResetWeeksAgo - The number of weeks since the profile was last reset,
+ * starting from 0 for profiles reset less than seven
+ * days ago. If the profile has never been reset it
+ * returns null. Since Fx56.
+ * @property {String} version - Version string e.g. "48.0a2"
+ * @since 35
+ */
+
+ /**
+ * @summary Search service configuration.
+ *
+ * @description From version 34 through 42 inclusive, a string was returned for the 'selectedSearchEngine'
+ * configuration instead of the object like 'search'.
+ *
+ * @typedef {String|Object} Mozilla.UITour.Configuration.Search
+ * @property {String} searchEngineIdentifier - The default engine's identifier
+ * @property {String[]} engines - Identifiers of visible engines
+ * @since 43
+ */
+
+ /**
+ * Sync status and device counts.
+ * @typedef {Object} Mozilla.UITour.Configuration.Sync
+ * @property {Boolean} setup - Whether sync is setup
+ * @property {Number} desktopDevices - Number of desktop devices
+ * @property {Number} mobileDevices - Number of mobile devices
+ * @property {Number} totalDevices - Total number of connected devices
+ * @since 50
+ */
+
+ /**
+ * FxA local status, including whether FxA is connected and the general
+ * account state.
+ * @typedef {Object} Mozilla.UITour.Configuration.FxA
+ * @property {Boolean} setup - Whether FxA is setup on this device. If false,
+ * no other properties will exist.
+ * @property {Boolean} accountStateOK - Whether the FxA account state is OK.
+ * If false, it probably means the account is unverified or the user has
+ * changed their password on another device and needs to update it here.
+ * In that case many other properties will not exist.
+ * @property {Mozilla.UITour.Configuration.BrowserServices} [browserServices] -
+ * Information about account services attached to this browser, and with
+ * special support implemented by this browser. You should not expect
+ * every accountService connected in this browser to get a special entry
+ * here. Indeed, what services, and in what circumstances they may appear
+ * here in the future is largely TBD.
+ * @since 71
+ */
+
+ /**
+ * FxA connections status - information about the account which typically
+ * isn't stored locally, so needs to be obtained from the FxA servers. As such,
+ * requesting this information is likely to be high-latency and may return
+ * incomplete data if there is a network or server error.
+ * @typedef {Object} Mozilla.UITour.Configuration.FxAConnections
+ * @property {Boolean} setup - Whether FxA is setup on this device. If false,
+ * no other properties will exist.
+ * @property {Number} [numOtherDevices] - Number of devices connected to this
+ * account, not counting this device.
+ * @property {Object.<String, Number>} [numDevicesByType] - A count of devices
+ * connected to the account by device 'type'. Valid values for type are
+ * defined by the FxA server but roughly correspond to form-factor with
+ * values like 'desktop', 'mobile', 'vr', etc.
+ * @property {Mozilla.UITour.Configuration.AccountServices} [accountServices] -
+ * Information about services attached to this account. These services
+ * may be enabled on devices or applications external to this
+ * browser and should not be confused with devices. For example, if the user
+ * has enabled Monitor or Lockwise on one or more devices - including on
+ * this device - that service will have a single entry here.
+ * @since 73
+ */
+
+ /**
+ * Information about clients attached to the account.
+ * An object. The key is a string ID of the attached service. A list of attached
+ * service IDs can be found
+ * `on our telemetry documentation site <https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution>`_.
+ * The value is a :js:func:`Mozilla.UITour.Configuration.AccountService`
+ * @typedef {Object.<string, Mozilla.UITour.Configuration.AccountService>} Mozilla.UITour.Configuration.AccountServices
+ * @since 71
+ */
+
+ /**
+ * Information about an account service
+ * @typedef {Object} Mozilla.UITour.Configuration.AccountService
+ * @property {String} id - The service ID. A list of attached
+ * service IDs can be found
+ * `on our telemetry documentation site <https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution>`_.
+ * @property {Number} lastAccessedWeeksAgo - How many weeks ago the service
+ * was accessed by this account.
+ * @since 71
+ */
+
+ /**
+ * Information about a services attached to the browser. All properties are
+ * optional and only exist if the service is enabled.
+ *
+ * @typedef {Object} Mozilla.UITour.Configuration.BrowserServices
+ * @property {Mozilla.UITour.Configuration.Sync} sync - If sync is configured
+ * @since 71
+ */
+
+ /**
+ * Array of UI :js:func:`Targets <Mozilla.UITour.Target>` currently available to be annotated.
+ * @typedef {Mozilla.UITour.Target[]} Mozilla.UITour.Configuration.AvailableTargets
+ */
+
+ /**
+ * Retrieve some information about the application/profile.
+ *
+ * @param {Mozilla.UITour.ConfigurationName} configName - Name of configuration to retrieve
+ * @param {Function} callback - Called with one argument containing the value of the configuration.
+ */
+ Mozilla.UITour.getConfiguration = function (configName, callback) {
+ _sendEvent("getConfiguration", {
+ callbackID: _waitForCallback(callback),
+ configuration: configName,
+ });
+ };
+
+ /**
+ * Set some value or take some action.
+ *
+ * Valid configuration names:
+ *
+ * defaultBrowser
+ * Try to set the application as the default web browser. Since Fx40
+ *
+ * @param {String} configName - Configuration name to set (e.g. "defaultBrowser")
+ * @param {String|Number|Boolean} [configValue] - Not currently used
+ *
+ * @since 40
+ * @example
+ * Mozilla.UITour.setConfiguration('defaultBrowser');
+ */
+ Mozilla.UITour.setConfiguration = function (configName, configValue) {
+ _sendEvent("setConfiguration", {
+ configuration: configName,
+ value: configValue,
+ });
+ };
+
+ /**
+ * Request the browser open the Firefox Accounts page.
+ *
+ * @param {Object} extraURLParams - An object containing additional
+ * parameters for the URL opened by the browser for reasons of promotional
+ * campaign tracking. Each attribute of the object must have a name that
+ * is a string, is "flow_id", "flow_begin_time", "device_id" or begins
+ * with `utm_` and contains only only alphanumeric characters, dashes or
+ * underscores. The values may be any string and will automatically be encoded.
+ * For Flow metrics, see details at https://mozilla.github.io/ecosystem-platform/docs/fxa-engineering/fxa-metrics#content-server
+ * @param {String} entrypoint - A string containing the entrypoint name.
+ * @param {String} email - A string containing the default email account
+ * for the URL opened by the browser.
+ * @since 31, 47 for `extraURLParams`
+ * @since 79 for "flow_id", "flow_begin_time", "device_id", "entrypoint_experiment",
+ * "entrypoint", "entrypoint_variation" fields.
+ * @example
+ * // Will open https://accounts.firefox.com/signup?entrypoint=uitour
+ * Mozilla.UITour.showFirefoxAccounts();
+ * @example
+ * // Will open:
+ * // https://accounts.firefox.com/signup?entrypoint=uitour&utm_foo=bar&utm_bar=baz
+ * Mozilla.UITour.showFirefoxAccounts({
+ * 'utm_foo': 'bar',
+ * 'utm_bar': 'baz'
+ * });
+ * @example
+ * // Will open:
+ * // https://accounts.firefox.com/?action=email&email=foo%40bar.com&entrypoint=uitour
+ * Mozilla.UITour.showFirefoxAccounts(null, null, "foo@bar.com");
+ * @example
+ * // Will open:
+ * // https://accounts.firefox.com/signup?entrypoint=sample
+ * Mozilla.UITour.showFirefoxAccounts(null, "sample");
+ * @example
+ * // Will open:
+ * // https://accounts.firefox.com/?action=email&email=foo%40bar.com&entrypoint=uitour&flow_id=c5b5ad7c4a94462afe4b9a7fbcca263dbd6c8409fb4539449c50c4a52544b2ed&flow_begin_time=1590680755812
+ * Mozilla.UITour.showFirefoxAccounts({
+ * flow_id: 'c5b5ad7c4a94462afe4b9a7fbcca263dbd6c8409fb4539449c50c4a52544b2ed',
+ * flow_begin_time: 1590680755812,
+ * device_id: '7e450f3337d3479b8582ea1c9bb5ba6c'
+ * }, "foo@bar.com");
+ */
+ Mozilla.UITour.showFirefoxAccounts = function (
+ extraURLParams,
+ entrypoint,
+ email
+ ) {
+ _sendEvent("showFirefoxAccounts", {
+ extraURLParams: JSON.stringify(extraURLParams),
+ entrypoint,
+ email,
+ });
+ };
+
+ /**
+ * Request the browser open the "Connect Another Device" Firefox Accounts page.
+ *
+ * @param {Object} extraURLParams - An object containing additional
+ * parameters for the URL opened by the browser for reasons of promotional
+ * campaign tracking. Each attribute of the object must have a name that
+ * is a string, is "flow_id", "flow_begin_time", "device_id" or begins
+ * with `utm_` and contains only only alphanumeric characters, dashes or
+ * underscores. The values may be any string and will automatically be encoded.
+ * For Flow metrics, see details at https://mozilla.github.io/ecosystem-platform/docs/fxa-engineering/fxa-metrics#content-server
+ * @since 59
+ * @example
+ * // Will open https://accounts.firefox.com/connect_another_device?entrypoint=uitour
+ * Mozilla.UITour.showConnectAnotherDevice();
+ * @example
+ * // Will open:
+ * // https://accounts.firefox.com/connect_another_device?entrypoint=uitour&utm_foo=bar&utm_bar=baz
+ * Mozilla.UITour.showConnectAnotherDevice({
+ * 'utm_foo': 'bar',
+ * 'utm_bar': 'baz'
+ * });
+ */
+ Mozilla.UITour.showConnectAnotherDevice = function (extraURLParams) {
+ _sendEvent("showConnectAnotherDevice", {
+ extraURLParams: JSON.stringify(extraURLParams),
+ });
+ };
+
+ /**
+ * Show a profile refresh/reset dialog, allowing users to choose to reomve
+ * add-ons and customizations as well as restore browser defaults, if possible.
+ * `getConfiguration('canReset')` should first be used to determine whether
+ * Refresh/Reset is possible for the user's build/profile.
+ * @since 48
+ * @see Mozilla.UITour.Configuration.CanReset
+ */
+ Mozilla.UITour.resetFirefox = function () {
+ _sendEvent("resetFirefox");
+ };
+
+ /**
+ * Add the specified customizable widget to the navigation toolbar.
+ *
+ * @param {Mozilla.UITour.Target} name - Identifier of the customizable widget.
+ * @param {Function} callback - Called with no arguments once the icon was successfully added to
+ * the toolbar. Not called if it doesn't succeed.
+ * @since 33.1
+ * @example
+ * Mozilla.UITour.addNavBarWidget('forget', function () {
+ * console.log('forget button added to toolbar');
+ * });
+ */
+ Mozilla.UITour.addNavBarWidget = function (name, callback) {
+ _sendEvent("addNavBarWidget", {
+ name,
+ callbackID: _waitForCallback(callback),
+ });
+ };
+
+ /**
+ * Set the specified search engine as the user-set default.
+ *
+ * See https://searchfox.org/mozilla-release/source/browser/locales/search/list.json
+ *
+ * @param {String} identifier - Identifier of the engine (e.g. 'yahoo').
+ * @see Mozilla.UITour.Configuration.Search
+ * @since 34
+ */
+ Mozilla.UITour.setDefaultSearchEngine = function (identifier) {
+ _sendEvent("setDefaultSearchEngine", {
+ identifier,
+ });
+ };
+
+ /**
+ * Sets a key+value pair as a treatment tag for recording in FHR.
+ * @param {String} name - tag name for the treatment
+ * @param {String} value - tag value for the treatment
+ * @since 34
+ * @see Mozilla.UITour.getTreatmentTag
+ * @example
+ * Mozilla.UITour.setTreatmentTag('srch-chg-action', 'Switch');
+ */
+ Mozilla.UITour.setTreatmentTag = function (name, value) {
+ _sendEvent("setTreatmentTag", {
+ name,
+ value,
+ });
+ };
+
+ /**
+ * Retrieved the value for a set FHR treatment tag.
+ *
+ * @param {String} name - Tag name to be retrieved
+ * @param {Function} callback - Called once the data has been retrieved
+ * @since 34
+ * @see Mozilla.UITour.setTreatmentTag
+ * @example
+ * Mozilla.UITour.getTreatmentTag('srch-chg-action', function(value) {
+ * console.log(value);
+ * });
+ */
+ Mozilla.UITour.getTreatmentTag = function (name, callback) {
+ _sendEvent("getTreatmentTag", {
+ name,
+ callbackID: _waitForCallback(callback),
+ });
+ };
+
+ /**
+ * Set the search term in the search box.
+ *
+ * This should have been implemented via `setConfiguration("searchTerm", …)`.
+ *
+ * @param {String} term - Search string e.g. 'Firefox'
+ * @since 34
+ */
+ Mozilla.UITour.setSearchTerm = function (term) {
+ _sendEvent("setSearchTerm", {
+ term,
+ });
+ };
+
+ /**
+ * @summary Opens the search box's panel.
+ *
+ * @description This should have been implemented via `showMenu("search", …)`.
+ *
+ * @param {Function} callback - Called once the panel has opened.
+ * @since 34
+ */
+ Mozilla.UITour.openSearchPanel = function (callback) {
+ _sendEvent("openSearchPanel", {
+ callbackID: _waitForCallback(callback),
+ });
+ };
+
+ /**
+ * @summary Force the reader mode icon to appear in the address bar regardless of whether
+ * heuristics determine it's appropriate.
+ *
+ * @description This is useful if you want to target an annotation (panel/highlight) on it
+ * but the tour page doesn't have much textual content.
+ */
+ Mozilla.UITour.forceShowReaderIcon = function () {
+ _sendEvent("forceShowReaderIcon");
+ };
+
+ /**
+ * Toggle into reader mode for the current tab. Once the user enters reader
+ * mode, the UITour document will not be active and therefore cannot call other
+ * UITour APIs.
+ */
+ Mozilla.UITour.toggleReaderMode = function () {
+ _sendEvent("toggleReaderMode");
+ };
+
+ /**
+ * @param {String} pane - Pane to open/switch the preferences to.
+ * Valid values match fragments on about:preferences and are subject to change e.g.:
+ *
+ * For the Preferences:
+ *
+ * - general
+ * - applications
+ * - sync
+ * - privacy
+ * - advanced
+ *
+ * To open to the options of sending telemetry, health report, crash reports,
+ * that is, the privacy pane > reports on the preferences.
+ * Please call `Mozilla.UITour.openPreferences("privacy-reports")`.
+ * UITour would do route mapping automatically.
+ *
+ * @since 42
+ */
+ Mozilla.UITour.openPreferences = function (pane) {
+ _sendEvent("openPreferences", {
+ pane,
+ });
+ };
+
+ /**
+ * @summary Closes the tab where this code is running. As usual, if the tab is in the
+ * foreground, the tab that was displayed before is selected.
+ *
+ * @description The last tab in the current window will never be closed, in which case
+ * this call will have no effect. The calling code is expected to take an
+ * action after a small timeout in order to handle this case, for example by
+ * displaying a goodbye message or a button to restart the tour.
+ * @since 46
+ */
+ Mozilla.UITour.closeTab = function () {
+ _sendEvent("closeTab");
+ };
+})();
+
+// Make this library Require-able.
+/* eslint-env commonjs */
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = Mozilla.UITour;
+}
diff --git a/browser/components/uitour/UITour.sys.mjs b/browser/components/uitour/UITour.sys.mjs
new file mode 100644
index 0000000000..048ad86432
--- /dev/null
+++ b/browser/components/uitour/UITour.sys.mjs
@@ -0,0 +1,2044 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+ ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
+ ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs",
+ TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs",
+ UIState: "resource://services-sync/UIState.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+// See LOG_LEVELS in Console.sys.mjs. Common examples: "All", "Info", "Warn", &
+// "Error".
+const PREF_LOG_LEVEL = "browser.uitour.loglevel";
+
+const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([
+ "forceShowReaderIcon",
+ "getConfiguration",
+ "getTreatmentTag",
+ "hideHighlight",
+ "hideInfo",
+ "hideMenu",
+ "ping",
+ "registerPageID",
+ "setConfiguration",
+ "setTreatmentTag",
+]);
+const MAX_BUTTONS = 4;
+
+// Array of which colorway/theme ids can be activated.
+XPCOMUtils.defineLazyGetter(lazy, "COLORWAY_IDS", () =>
+ [...lazy.BuiltInThemes.builtInThemeMap.keys()].filter(
+ id =>
+ id.endsWith("-colorway@mozilla.org") &&
+ !lazy.BuiltInThemes.themeIsExpired(id)
+ )
+);
+
+// Prefix for any target matching a search engine.
+const TARGET_SEARCHENGINE_PREFIX = "searchEngine-";
+
+// Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ maxLogLevelPref: PREF_LOG_LEVEL,
+ prefix: "UITour",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+export var UITour = {
+ url: null,
+ /* Map from browser chrome windows to a Set of <browser>s in which a tour is open (both visible and hidden) */
+ tourBrowsersByWindow: new WeakMap(),
+ // Menus opened by api users explictly through `Mozilla.UITour.showMenu` call
+ noautohideMenus: new Set(),
+ availableTargetsCache: new WeakMap(),
+ clearAvailableTargetsCache() {
+ this.availableTargetsCache = new WeakMap();
+ },
+
+ _annotationPanelMutationObservers: new WeakMap(),
+
+ highlightEffects: ["random", "wobble", "zoom", "color", "focus-outline"],
+ targets: new Map([
+ [
+ "accountStatus",
+ {
+ query: "#appMenu-fxa-label2",
+ // This is a fake widgetName starting with the "appMenu-" prefix so we know
+ // to automatically open the appMenu when annotating this target.
+ widgetName: "appMenu-fxa-label2",
+ },
+ ],
+ [
+ "addons",
+ {
+ query: "#appMenu-extensions-themes-button",
+ },
+ ],
+ [
+ "appMenu",
+ {
+ addTargetListener: (aDocument, aCallback) => {
+ let panelPopup = aDocument.defaultView.PanelUI.panel;
+ panelPopup.addEventListener("popupshown", aCallback);
+ },
+ query: "#PanelUI-button",
+ removeTargetListener: (aDocument, aCallback) => {
+ let panelPopup = aDocument.defaultView.PanelUI.panel;
+ panelPopup.removeEventListener("popupshown", aCallback);
+ },
+ },
+ ],
+ ["backForward", { query: "#back-button" }],
+ ["bookmarks", { query: "#bookmarks-menu-button" }],
+ [
+ "forget",
+ {
+ allowAdd: true,
+ query: "#panic-button",
+ widgetName: "panic-button",
+ },
+ ],
+ ["help", { query: "#appMenu-help-button2" }],
+ ["home", { query: "#home-button" }],
+ [
+ "logins",
+ {
+ query: "#appMenu-passwords-button",
+ },
+ ],
+ [
+ "pocket",
+ {
+ allowAdd: true,
+ query: "#save-to-pocket-button",
+ },
+ ],
+ [
+ "privateWindow",
+ {
+ query: "#appMenu-new-private-window-button2",
+ },
+ ],
+ [
+ "quit",
+ {
+ query: "#appMenu-quit-button2",
+ },
+ ],
+ ["readerMode-urlBar", { query: "#reader-mode-button" }],
+ [
+ "search",
+ {
+ infoPanelOffsetX: 18,
+ infoPanelPosition: "after_start",
+ query: "#searchbar",
+ widgetName: "search-container",
+ },
+ ],
+ [
+ "searchIcon",
+ {
+ query: aDocument => {
+ let searchbar = aDocument.getElementById("searchbar");
+ return searchbar.querySelector(".searchbar-search-button");
+ },
+ widgetName: "search-container",
+ },
+ ],
+ [
+ "selectedTabIcon",
+ {
+ query: aDocument => {
+ let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
+ let element = selectedtab.iconImage;
+ if (!element || !UITour.isElementVisible(element)) {
+ return null;
+ }
+ return element;
+ },
+ },
+ ],
+ [
+ "urlbar",
+ {
+ query: "#urlbar",
+ widgetName: "urlbar-container",
+ },
+ ],
+ [
+ "pageAction-bookmark",
+ {
+ query: aDocument => {
+ // The bookmark's urlbar page action button is pre-defined in the DOM.
+ // It would be hidden if toggled off from the urlbar.
+ let node = aDocument.getElementById("star-button-box");
+ return node && !node.hidden ? node : null;
+ },
+ },
+ ],
+ ]),
+
+ init() {
+ lazy.log.debug("Initializing UITour");
+ // Lazy getter is initialized here so it can be replicated any time
+ // in a test.
+ delete this.url;
+ XPCOMUtils.defineLazyGetter(this, "url", function () {
+ return Services.urlFormatter.formatURLPref("browser.uitour.url");
+ });
+
+ // Clear the availableTargetsCache on widget changes.
+ let listenerMethods = [
+ "onWidgetAdded",
+ "onWidgetMoved",
+ "onWidgetRemoved",
+ "onWidgetReset",
+ "onAreaReset",
+ ];
+ lazy.CustomizableUI.addListener(
+ listenerMethods.reduce((listener, method) => {
+ listener[method] = () => this.clearAvailableTargetsCache();
+ return listener;
+ }, {})
+ );
+
+ Services.obs.addObserver(this, lazy.UIState.ON_UPDATE);
+ },
+
+ getNodeFromDocument(aDocument, aQuery) {
+ let viewCacheTemplate = aDocument.getElementById("appMenu-viewCache");
+ return (
+ aDocument.querySelector(aQuery) ||
+ viewCacheTemplate.content.querySelector(aQuery)
+ );
+ },
+
+ onPageEvent(aEvent, aBrowser) {
+ let browser = aBrowser;
+ let window = browser.ownerGlobal;
+
+ // Does the window have tabs? We need to make sure since windowless browsers do
+ // not have tabs.
+ if (!window.gBrowser) {
+ // When using windowless browsers we don't have a valid |window|. If that's the case,
+ // use the most recent window as a target for UITour functions (see Bug 1111022).
+ window = Services.wm.getMostRecentWindow("navigator:browser");
+ }
+
+ lazy.log.debug("onPageEvent:", aEvent.detail);
+
+ if (typeof aEvent.detail != "object") {
+ lazy.log.warn("Malformed event - detail not an object");
+ return false;
+ }
+
+ let action = aEvent.detail.action;
+ if (typeof action != "string" || !action) {
+ lazy.log.warn("Action not defined");
+ return false;
+ }
+
+ let data = aEvent.detail.data;
+ if (typeof data != "object") {
+ lazy.log.warn("Malformed event - data not an object");
+ return false;
+ }
+
+ if (
+ (aEvent.pageVisibilityState == "hidden" ||
+ aEvent.pageVisibilityState == "unloaded") &&
+ !BACKGROUND_PAGE_ACTIONS_ALLOWED.has(action)
+ ) {
+ lazy.log.warn(
+ "Ignoring disallowed action from a hidden page:",
+ action,
+ aEvent.pageVisibilityState
+ );
+ return false;
+ }
+
+ switch (action) {
+ case "registerPageID": {
+ break;
+ }
+
+ case "showHighlight": {
+ let targetPromise = this.getTarget(window, data.target);
+ targetPromise
+ .then(target => {
+ if (!target.node) {
+ lazy.log.error(
+ "UITour: Target could not be resolved: " + data.target
+ );
+ return;
+ }
+ let effect = undefined;
+ if (this.highlightEffects.includes(data.effect)) {
+ effect = data.effect;
+ }
+ this.showHighlight(window, target, effect);
+ })
+ .catch(lazy.log.error);
+ break;
+ }
+
+ case "hideHighlight": {
+ this.hideHighlight(window);
+ break;
+ }
+
+ case "showInfo": {
+ let targetPromise = this.getTarget(window, data.target, true);
+ targetPromise
+ .then(target => {
+ if (!target.node) {
+ lazy.log.error(
+ "UITour: Target could not be resolved: " + data.target
+ );
+ return;
+ }
+
+ let iconURL = null;
+ if (typeof data.icon == "string") {
+ iconURL = this.resolveURL(browser, data.icon);
+ }
+
+ let buttons = [];
+ if (Array.isArray(data.buttons) && data.buttons.length) {
+ for (let buttonData of data.buttons) {
+ if (
+ typeof buttonData == "object" &&
+ typeof buttonData.label == "string" &&
+ typeof buttonData.callbackID == "string"
+ ) {
+ let callback = buttonData.callbackID;
+ let button = {
+ label: buttonData.label,
+ callback: event => {
+ this.sendPageCallback(browser, callback);
+ },
+ };
+
+ if (typeof buttonData.icon == "string") {
+ button.iconURL = this.resolveURL(browser, buttonData.icon);
+ }
+
+ if (typeof buttonData.style == "string") {
+ button.style = buttonData.style;
+ }
+
+ buttons.push(button);
+
+ if (buttons.length == MAX_BUTTONS) {
+ lazy.log.warn(
+ "showInfo: Reached limit of allowed number of buttons"
+ );
+ break;
+ }
+ }
+ }
+ }
+
+ let infoOptions = {};
+ if (typeof data.closeButtonCallbackID == "string") {
+ infoOptions.closeButtonCallback = () => {
+ this.sendPageCallback(browser, data.closeButtonCallbackID);
+ };
+ }
+ if (typeof data.targetCallbackID == "string") {
+ infoOptions.targetCallback = details => {
+ this.sendPageCallback(browser, data.targetCallbackID, details);
+ };
+ }
+
+ this.showInfo(
+ window,
+ target,
+ data.title,
+ data.text,
+ iconURL,
+ buttons,
+ infoOptions
+ );
+ })
+ .catch(lazy.log.error);
+ break;
+ }
+
+ case "hideInfo": {
+ this.hideInfo(window);
+ break;
+ }
+
+ case "showMenu": {
+ this.noautohideMenus.add(data.name);
+ this.showMenu(window, data.name, () => {
+ if (typeof data.showCallbackID == "string") {
+ this.sendPageCallback(browser, data.showCallbackID);
+ }
+ });
+ break;
+ }
+
+ case "hideMenu": {
+ this.noautohideMenus.delete(data.name);
+ this.hideMenu(window, data.name);
+ break;
+ }
+
+ case "showNewTab": {
+ this.showNewTab(window, browser);
+ break;
+ }
+
+ case "getConfiguration": {
+ if (typeof data.configuration != "string") {
+ lazy.log.warn("getConfiguration: No configuration option specified");
+ return false;
+ }
+
+ this.getConfiguration(
+ browser,
+ window,
+ data.configuration,
+ data.callbackID
+ );
+ break;
+ }
+
+ case "setConfiguration": {
+ if (typeof data.configuration != "string") {
+ lazy.log.warn("setConfiguration: No configuration option specified");
+ return false;
+ }
+
+ this.setConfiguration(window, data.configuration, data.value);
+ break;
+ }
+
+ case "openPreferences": {
+ if (typeof data.pane != "string" && typeof data.pane != "undefined") {
+ lazy.log.warn("openPreferences: Invalid pane specified");
+ return false;
+ }
+ window.openPreferences(data.pane);
+ break;
+ }
+
+ case "showFirefoxAccounts": {
+ Promise.resolve()
+ .then(() => {
+ return lazy.FxAccounts.canConnectAccount();
+ })
+ .then(canConnect => {
+ if (!canConnect) {
+ lazy.log.warn("showFirefoxAccounts: can't currently connect");
+ return null;
+ }
+ return data.email
+ ? lazy.FxAccounts.config.promiseEmailURI(
+ data.email,
+ data.entrypoint || "uitour"
+ )
+ : lazy.FxAccounts.config.promiseConnectAccountURI(
+ data.entrypoint || "uitour"
+ );
+ })
+ .then(uri => {
+ if (!uri) {
+ return;
+ }
+ const url = new URL(uri);
+ // Call our helper to validate extraURLParams and populate URLSearchParams
+ if (!this._populateURLParams(url, data.extraURLParams)) {
+ lazy.log.warn(
+ "showFirefoxAccounts: invalid campaign args specified"
+ );
+ return;
+ }
+ // We want to replace the current tab.
+ browser.loadURI(url.URI, {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.createNullPrincipal({}),
+ });
+ });
+ break;
+ }
+
+ case "showConnectAnotherDevice": {
+ lazy.FxAccounts.config
+ .promiseConnectDeviceURI(data.entrypoint || "uitour")
+ .then(uri => {
+ const url = new URL(uri);
+ // Call our helper to validate extraURLParams and populate URLSearchParams
+ if (!this._populateURLParams(url, data.extraURLParams)) {
+ lazy.log.warn(
+ "showConnectAnotherDevice: invalid campaign args specified"
+ );
+ return;
+ }
+
+ // We want to replace the current tab.
+ browser.loadURI(url.URI, {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.createNullPrincipal({}),
+ });
+ });
+ break;
+ }
+
+ case "resetFirefox": {
+ // Open a reset profile dialog window.
+ if (lazy.ResetProfile.resetSupported()) {
+ lazy.ResetProfile.openConfirmationDialog(window);
+ }
+ break;
+ }
+
+ case "addNavBarWidget": {
+ // Add a widget to the toolbar
+ let targetPromise = this.getTarget(window, data.name);
+ targetPromise
+ .then(target => {
+ this.addNavBarWidget(target, browser, data.callbackID);
+ })
+ .catch(lazy.log.error);
+ break;
+ }
+
+ case "setDefaultSearchEngine": {
+ let enginePromise = this.selectSearchEngine(data.identifier);
+ enginePromise.catch(console.error);
+ break;
+ }
+
+ case "setTreatmentTag": {
+ let name = data.name;
+ let value = data.value;
+ Services.prefs.setStringPref("browser.uitour.treatment." + name, value);
+ // The notification is only meant to be used in tests.
+ UITourHealthReport.recordTreatmentTag(name, value).then(() =>
+ this.notify("TreatmentTag:TelemetrySent")
+ );
+ break;
+ }
+
+ case "getTreatmentTag": {
+ let name = data.name;
+ let value;
+ try {
+ value = Services.prefs.getStringPref(
+ "browser.uitour.treatment." + name
+ );
+ } catch (ex) {}
+ this.sendPageCallback(browser, data.callbackID, { value });
+ break;
+ }
+
+ case "setSearchTerm": {
+ let targetPromise = this.getTarget(window, "search");
+ targetPromise.then(target => {
+ let searchbar = target.node;
+ searchbar.value = data.term;
+ searchbar.updateGoButtonVisibility();
+ });
+ break;
+ }
+
+ case "openSearchPanel": {
+ let targetPromise = this.getTarget(window, "search");
+ targetPromise
+ .then(target => {
+ let searchbar = target.node;
+
+ if (searchbar.textbox.open) {
+ this.sendPageCallback(browser, data.callbackID);
+ } else {
+ let onPopupShown = () => {
+ searchbar.textbox.popup.removeEventListener(
+ "popupshown",
+ onPopupShown
+ );
+ this.sendPageCallback(browser, data.callbackID);
+ };
+
+ searchbar.textbox.popup.addEventListener(
+ "popupshown",
+ onPopupShown
+ );
+ searchbar.openSuggestionsPanel();
+ }
+ })
+ .catch(console.error);
+ break;
+ }
+
+ case "ping": {
+ if (typeof data.callbackID == "string") {
+ this.sendPageCallback(browser, data.callbackID);
+ }
+ break;
+ }
+
+ case "forceShowReaderIcon": {
+ lazy.AboutReaderParent.forceShowReaderIcon(browser);
+ break;
+ }
+
+ case "toggleReaderMode": {
+ let targetPromise = this.getTarget(window, "readerMode-urlBar");
+ targetPromise.then(target => {
+ lazy.AboutReaderParent.toggleReaderMode({ target: target.node });
+ });
+ break;
+ }
+
+ case "closeTab": {
+ // Find the <tabbrowser> element of the <browser> for which the event
+ // was generated originally. If the browser where the UI tour is loaded
+ // is windowless, just ignore the request to close the tab. The request
+ // is also ignored if this is the only tab in the window.
+ let tabBrowser = browser.ownerGlobal.gBrowser;
+ if (tabBrowser && tabBrowser.browsers.length > 1) {
+ tabBrowser.removeTab(tabBrowser.getTabForBrowser(browser));
+ }
+ break;
+ }
+
+ case "showProtectionReport": {
+ this.showProtectionReport(window, browser);
+ break;
+ }
+ }
+
+ // For performance reasons, only call initForBrowser if we did something
+ // that will require a teardownTourForBrowser call later.
+ // getConfiguration (called from about:home) doesn't require any future
+ // uninitialization.
+ if (action != "getConfiguration") {
+ this.initForBrowser(browser, window);
+ }
+
+ return true;
+ },
+
+ initForBrowser(aBrowser, window) {
+ let gBrowser = window.gBrowser;
+
+ if (gBrowser) {
+ gBrowser.tabContainer.addEventListener("TabSelect", this);
+ }
+
+ if (!this.tourBrowsersByWindow.has(window)) {
+ this.tourBrowsersByWindow.set(window, new Set());
+ }
+ this.tourBrowsersByWindow.get(window).add(aBrowser);
+
+ Services.obs.addObserver(this, "message-manager-close");
+
+ window.addEventListener("SSWindowClosing", this);
+ },
+
+ handleEvent(aEvent) {
+ lazy.log.debug("handleEvent: type =", aEvent.type, "event =", aEvent);
+ switch (aEvent.type) {
+ case "TabSelect": {
+ let window = aEvent.target.ownerGlobal;
+
+ // Teardown the browser of the tab we just switched away from.
+ if (aEvent.detail && aEvent.detail.previousTab) {
+ let previousTab = aEvent.detail.previousTab;
+ let openTourWindows = this.tourBrowsersByWindow.get(window);
+ if (openTourWindows.has(previousTab.linkedBrowser)) {
+ this.teardownTourForBrowser(
+ window,
+ previousTab.linkedBrowser,
+ false
+ );
+ }
+ }
+
+ break;
+ }
+
+ case "SSWindowClosing": {
+ let window = aEvent.target;
+ this.teardownTourForWindow(window);
+ break;
+ }
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ lazy.log.debug("observe: aTopic =", aTopic);
+ switch (aTopic) {
+ // The browser message manager is disconnected when the <browser> is
+ // destroyed and we want to teardown at that point.
+ case "message-manager-close": {
+ for (let window of Services.wm.getEnumerator("navigator:browser")) {
+ if (window.closed) {
+ continue;
+ }
+
+ let tourBrowsers = this.tourBrowsersByWindow.get(window);
+ if (!tourBrowsers) {
+ continue;
+ }
+
+ for (let browser of tourBrowsers) {
+ let messageManager = browser.messageManager;
+ if (!messageManager || aSubject == messageManager) {
+ this.teardownTourForBrowser(window, browser, true);
+ }
+ }
+ }
+ break;
+ }
+ case lazy.UIState.ON_UPDATE: {
+ let syncState = lazy.UIState.get();
+ this.notify("FxA:SignedInStateChange", { status: syncState.status });
+ break;
+ }
+ }
+ },
+
+ // Given a string that is a JSONified represenation of an object with
+ // additional "flow_id", "flow_begin_time", "device_id", utm_* URL params
+ // that should be appended, validate and append them to the passed URL object.
+ // Returns true if the params were validated and appended, and false if the
+ // request should be ignored.
+ _populateURLParams(url, extraURLParams) {
+ const FLOW_ID_LENGTH = 64;
+ const FLOW_BEGIN_TIME_LENGTH = 13;
+
+ // We are extra paranoid about what params we allow to be appended.
+ if (typeof extraURLParams == "undefined") {
+ // no params, so it's all good.
+ return true;
+ }
+ if (typeof extraURLParams != "string") {
+ lazy.log.warn("_populateURLParams: extraURLParams is not a string");
+ return false;
+ }
+ let urlParams;
+ try {
+ if (extraURLParams) {
+ urlParams = JSON.parse(extraURLParams);
+ if (typeof urlParams != "object") {
+ lazy.log.warn(
+ "_populateURLParams: extraURLParams is not a stringified object"
+ );
+ return false;
+ }
+ }
+ } catch (ex) {
+ lazy.log.warn("_populateURLParams: extraURLParams is not a JSON object");
+ return false;
+ }
+ if (urlParams) {
+ // Expected to JSON parse the following for FxA flow parameters:
+ //
+ // {String} flow_id - Flow Id, such as '5445b28b8b7ba6cf71e345f8fff4bc59b2a514f78f3e2cc99b696449427fd445'
+ // {Number} flow_begin_time - Flow begin timestamp, such as 1590780440325
+ // {String} device_id - Device Id, such as '7e450f3337d3479b8582ea1c9bb5ba6c'
+ if (
+ (urlParams.flow_begin_time &&
+ urlParams.flow_begin_time.toString().length !==
+ FLOW_BEGIN_TIME_LENGTH) ||
+ (urlParams.flow_id && urlParams.flow_id.length !== FLOW_ID_LENGTH)
+ ) {
+ lazy.log.warn(
+ "_populateURLParams: flow parameters are not properly structured"
+ );
+ return false;
+ }
+
+ // The regex that the name of each param must match - there's no
+ // character restriction on the value - they will be escaped as necessary.
+ let reSimpleString = /^[-_a-zA-Z0-9]*$/;
+ for (let name in urlParams) {
+ let value = urlParams[name];
+ const validName =
+ name.startsWith("utm_") ||
+ name === "entrypoint_experiment" ||
+ name === "entrypoint_variation" ||
+ name === "flow_begin_time" ||
+ name === "flow_id" ||
+ name === "device_id";
+ if (
+ typeof name != "string" ||
+ !validName ||
+ !reSimpleString.test(name)
+ ) {
+ lazy.log.warn("_populateURLParams: invalid campaign param specified");
+ return false;
+ }
+ url.searchParams.append(name, value);
+ }
+ }
+ return true;
+ },
+ /**
+ * Tear down a tour from a tab e.g. upon switching/closing tabs.
+ */
+ async teardownTourForBrowser(aWindow, aBrowser, aTourPageClosing = false) {
+ lazy.log.debug(
+ "teardownTourForBrowser: aBrowser = ",
+ aBrowser,
+ aTourPageClosing
+ );
+
+ let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow);
+ if (aTourPageClosing && openTourBrowsers) {
+ openTourBrowsers.delete(aBrowser);
+ }
+
+ this.hideHighlight(aWindow);
+ this.hideInfo(aWindow);
+
+ await this.removePanelListeners(aWindow);
+
+ this.noautohideMenus.clear();
+
+ // If there are no more tour tabs left in the window, teardown the tour for the whole window.
+ if (!openTourBrowsers || openTourBrowsers.size == 0) {
+ this.teardownTourForWindow(aWindow);
+ }
+ },
+
+ /**
+ * Remove the listeners to a panel when tearing the tour down.
+ */
+ async removePanelListeners(aWindow) {
+ let panels = [
+ {
+ name: "appMenu",
+ node: aWindow.PanelUI.panel,
+ events: [
+ ["popuphidden", this.onPanelHidden],
+ ["popuphiding", this.onAppMenuHiding],
+ ["ViewShowing", this.onAppMenuSubviewShowing],
+ ],
+ },
+ ];
+ for (let panel of panels) {
+ // Ensure the menu panel is hidden and clean up panel listeners after calling hideMenu.
+ if (panel.node.state != "closed") {
+ await new Promise(resolve => {
+ panel.node.addEventListener("popuphidden", resolve, { once: true });
+ this.hideMenu(aWindow, panel.name);
+ });
+ }
+ for (let [name, listener] of panel.events) {
+ panel.node.removeEventListener(name, listener);
+ }
+ }
+ },
+
+ /**
+ * Tear down all tours for a ChromeWindow.
+ */
+ teardownTourForWindow(aWindow) {
+ lazy.log.debug("teardownTourForWindow");
+ aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
+ aWindow.removeEventListener("SSWindowClosing", this);
+
+ this.tourBrowsersByWindow.delete(aWindow);
+ },
+
+ // This function is copied to UITourListener.
+ isSafeScheme(aURI) {
+ let allowedSchemes = new Set(["https", "about"]);
+ if (!Services.prefs.getBoolPref("browser.uitour.requireSecure")) {
+ allowedSchemes.add("http");
+ }
+
+ if (!allowedSchemes.has(aURI.scheme)) {
+ lazy.log.error("Unsafe scheme:", aURI.scheme);
+ return false;
+ }
+
+ return true;
+ },
+
+ resolveURL(aBrowser, aURL) {
+ try {
+ let uri = Services.io.newURI(aURL, null, aBrowser.currentURI);
+
+ if (!this.isSafeScheme(uri)) {
+ return null;
+ }
+
+ return uri.spec;
+ } catch (e) {}
+
+ return null;
+ },
+
+ sendPageCallback(aBrowser, aCallbackID, aData = {}) {
+ let detail = { data: aData, callbackID: aCallbackID };
+ lazy.log.debug("sendPageCallback", detail);
+ let contextToVisit = aBrowser.browsingContext;
+ let global = contextToVisit.currentWindowGlobal;
+ let actor = global.getActor("UITour");
+ actor.sendAsyncMessage("UITour:SendPageCallback", detail);
+ },
+
+ isElementVisible(aElement) {
+ let targetStyle = aElement.ownerGlobal.getComputedStyle(aElement);
+ return (
+ !aElement.ownerDocument.hidden &&
+ targetStyle.display != "none" &&
+ targetStyle.visibility == "visible"
+ );
+ },
+
+ getTarget(aWindow, aTargetName, aSticky = false) {
+ lazy.log.debug("getTarget:", aTargetName);
+ if (typeof aTargetName != "string" || !aTargetName) {
+ lazy.log.warn("getTarget: Invalid target name specified");
+ return Promise.reject("Invalid target name specified");
+ }
+
+ let targetObject = this.targets.get(aTargetName);
+ if (!targetObject) {
+ lazy.log.warn(
+ "getTarget: The specified target name is not in the allowed set"
+ );
+ return Promise.reject(
+ "The specified target name is not in the allowed set"
+ );
+ }
+
+ return new Promise(resolve => {
+ let targetQuery = targetObject.query;
+ aWindow.PanelUI.ensureReady()
+ .then(() => {
+ let node;
+ if (typeof targetQuery == "function") {
+ try {
+ node = targetQuery(aWindow.document);
+ } catch (ex) {
+ lazy.log.warn("getTarget: Error running target query:", ex);
+ node = null;
+ }
+ } else {
+ node = this.getNodeFromDocument(aWindow.document, targetQuery);
+ }
+
+ resolve({
+ addTargetListener: targetObject.addTargetListener,
+ infoPanelOffsetX: targetObject.infoPanelOffsetX,
+ infoPanelOffsetY: targetObject.infoPanelOffsetY,
+ infoPanelPosition: targetObject.infoPanelPosition,
+ node,
+ removeTargetListener: targetObject.removeTargetListener,
+ targetName: aTargetName,
+ widgetName: targetObject.widgetName,
+ allowAdd: targetObject.allowAdd,
+ });
+ })
+ .catch(lazy.log.error);
+ });
+ },
+
+ targetIsInAppMenu(aTarget) {
+ let targetElement = aTarget.node;
+ // Use the widget for filtering if it exists since the target may be the icon inside.
+ if (aTarget.widgetName) {
+ let doc = aTarget.node.ownerGlobal.document;
+ targetElement =
+ doc.getElementById(aTarget.widgetName) ||
+ lazy.PanelMultiView.getViewNode(doc, aTarget.widgetName);
+ }
+
+ return targetElement.id.startsWith("appMenu-");
+ },
+
+ /**
+ * Called before opening or after closing a highlight or an info tooltip to see if
+ * we need to open or close the menu to see the annotation's anchor.
+ *
+ * @param {ChromeWindow} aWindow the chrome window
+ * @param {bool} aShouldOpen true means we should open the menu, otherwise false
+ * @param {Object} aOptions Extra config arguments, example `autohide: true`.
+ */
+ _setMenuStateForAnnotation(aWindow, aShouldOpen, aOptions = {}) {
+ lazy.log.debug(
+ "_setMenuStateForAnnotation: Menu is expected to be:",
+ aShouldOpen ? "open" : "closed"
+ );
+ let menu = aWindow.PanelUI.panel;
+
+ // If the panel is in the desired state, we're done.
+ let panelIsOpen = menu.state != "closed";
+ if (aShouldOpen == panelIsOpen) {
+ lazy.log.debug(
+ "_setMenuStateForAnnotation: Menu already in expected state"
+ );
+ return Promise.resolve();
+ }
+
+ // Actually show or hide the menu
+ let promise = null;
+ if (aShouldOpen) {
+ lazy.log.debug("_setMenuStateForAnnotation: Opening the menu");
+ promise = new Promise(resolve => {
+ this.showMenu(aWindow, "appMenu", resolve, aOptions);
+ });
+ } else if (!this.noautohideMenus.has("appMenu")) {
+ // If the menu was opened explictly by api user through `Mozilla.UITour.showMenu`,
+ // it should be closed explictly by api user through `Mozilla.UITour.hideMenu`.
+ // So we shouldn't get to here to close it for the highlight/info annotation.
+ lazy.log.debug("_setMenuStateForAnnotation: Closing the menu");
+ promise = new Promise(resolve => {
+ menu.addEventListener("popuphidden", resolve, { once: true });
+ this.hideMenu(aWindow, "appMenu");
+ });
+ }
+ return promise;
+ },
+
+ /**
+ * Ensure the target's visibility and the open/close states of menus for the target.
+ *
+ * @param {ChromeWindow} aChromeWindow The chrome window
+ * @param {Object} aTarget The target on which we show highlight or show info.
+ * @param {Object} options Extra config arguments, example `autohide: true`.
+ */
+ async _ensureTarget(aChromeWindow, aTarget, aOptions = {}) {
+ let shouldOpenAppMenu = false;
+ if (this.targetIsInAppMenu(aTarget)) {
+ shouldOpenAppMenu = true;
+ }
+
+ // Prevent showing a panel at an undefined position, but when it's tucked
+ // away inside a panel, we skip this check.
+ if (
+ !aTarget.node.closest("panelview") &&
+ !this.isElementVisible(aTarget.node)
+ ) {
+ return Promise.reject(
+ `_ensureTarget: Reject the ${
+ aTarget.name || aTarget.targetName
+ } target since it isn't visible.`
+ );
+ }
+
+ let menuClosePromises = [];
+ if (!shouldOpenAppMenu) {
+ menuClosePromises.push(
+ this._setMenuStateForAnnotation(aChromeWindow, false)
+ );
+ }
+
+ let promise = Promise.all(menuClosePromises);
+ await promise;
+ if (shouldOpenAppMenu) {
+ promise = this._setMenuStateForAnnotation(aChromeWindow, true, aOptions);
+ }
+ return promise;
+ },
+
+ /**
+ * The node to which a highlight or notification(-popup) is anchored is sometimes
+ * obscured because it may be inside an overflow menu. This function should figure
+ * that out and offer the overflow chevron as an alternative.
+ *
+ * @param {ChromeWindow} aChromeWindow The chrome window
+ * @param {Object} aTarget The target object whose node is supposed to be the anchor
+ * @type {Node}
+ */
+ async _correctAnchor(aChromeWindow, aTarget) {
+ // PanelMultiView's like the AppMenu might shuffle the DOM, which might result
+ // in our anchor being invalidated if it was anonymous content (since the XBL
+ // binding it belonged to got destroyed). We work around this by re-querying for
+ // the node and stuffing it into the old anchor structure.
+ let refreshedTarget = await this.getTarget(
+ aChromeWindow,
+ aTarget.targetName
+ );
+ let node = (aTarget.node = refreshedTarget.node);
+ // If the target is in the overflow panel, just return the overflow button.
+ if (node.closest("#widget-overflow-mainView")) {
+ return lazy.CustomizableUI.getWidget(node.id).forWindow(aChromeWindow)
+ .anchor;
+ }
+ return node;
+ },
+
+ /**
+ * @param aChromeWindow The chrome window that the highlight is in. Necessary since some targets
+ * are in a sub-frame so the defaultView is not the same as the chrome
+ * window.
+ * @param aTarget The element to highlight.
+ * @param aEffect (optional) The effect to use from UITour.highlightEffects or "none".
+ * @param aOptions (optional) Extra config arguments, example `autohide: true`.
+ * @see UITour.highlightEffects
+ */
+ async showHighlight(aChromeWindow, aTarget, aEffect = "none", aOptions = {}) {
+ let showHighlightElement = aAnchorEl => {
+ let highlighter = this.getHighlightAndMaybeCreate(aChromeWindow.document);
+
+ let effect = aEffect;
+ if (effect == "random") {
+ // Exclude "random" from the randomly selected effects.
+ let randomEffect =
+ 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1));
+ if (randomEffect == this.highlightEffects.length) {
+ randomEffect--;
+ } // On the order of 1 in 2^62 chance of this happening.
+ effect = this.highlightEffects[randomEffect];
+ }
+ // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays.
+ highlighter.setAttribute("active", "none");
+ aChromeWindow.getComputedStyle(highlighter).animationName;
+ highlighter.setAttribute("active", effect);
+ highlighter.parentElement.setAttribute("targetName", aTarget.targetName);
+ highlighter.parentElement.hidden = false;
+
+ let highlightAnchor = aAnchorEl;
+ let targetRect = highlightAnchor.getBoundingClientRect();
+ let highlightHeight = targetRect.height;
+ let highlightWidth = targetRect.width;
+
+ if (this.targetIsInAppMenu(aTarget)) {
+ highlighter.classList.remove("rounded-highlight");
+ } else {
+ highlighter.classList.add("rounded-highlight");
+ }
+ if (
+ highlightAnchor.classList.contains("toolbarbutton-1") &&
+ highlightAnchor.getAttribute("cui-areatype") === "toolbar" &&
+ highlightAnchor.getAttribute("overflowedItem") !== "true"
+ ) {
+ // A toolbar button in navbar has its clickable area an
+ // inner-contained square while the button component itself is a tall
+ // rectangle. We adjust the highlight area to a square as well.
+ highlightHeight = highlightWidth;
+ }
+
+ highlighter.style.height = highlightHeight + "px";
+ highlighter.style.width = highlightWidth + "px";
+
+ // Close a previous highlight so we can relocate the panel.
+ if (
+ highlighter.parentElement.state == "showing" ||
+ highlighter.parentElement.state == "open"
+ ) {
+ lazy.log.debug("showHighlight: Closing previous highlight first");
+ highlighter.parentElement.hidePopup();
+ }
+ /* The "overlap" position anchors from the top-left but we want to centre highlights at their
+ minimum size. */
+ let highlightWindow = aChromeWindow;
+ let highlightStyle = highlightWindow.getComputedStyle(highlighter);
+ let highlightHeightWithMin = Math.max(
+ highlightHeight,
+ parseFloat(highlightStyle.minHeight)
+ );
+ let highlightWidthWithMin = Math.max(
+ highlightWidth,
+ parseFloat(highlightStyle.minWidth)
+ );
+ let offsetX = (targetRect.width - highlightWidthWithMin) / 2;
+ let offsetY = (targetRect.height - highlightHeightWithMin) / 2;
+ this._addAnnotationPanelMutationObserver(highlighter.parentElement);
+ highlighter.parentElement.openPopup(
+ highlightAnchor,
+ "overlap",
+ offsetX,
+ offsetY
+ );
+ };
+
+ try {
+ await this._ensureTarget(aChromeWindow, aTarget, aOptions);
+ let anchorEl = await this._correctAnchor(aChromeWindow, aTarget);
+ showHighlightElement(anchorEl);
+ } catch (e) {
+ lazy.log.warn(e);
+ }
+ },
+
+ _hideHighlightElement(aWindow) {
+ let highlighter = this.getHighlightAndMaybeCreate(aWindow.document);
+ this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
+ highlighter.parentElement.hidePopup();
+ highlighter.removeAttribute("active");
+ },
+
+ hideHighlight(aWindow) {
+ this._hideHighlightElement(aWindow);
+ this._setMenuStateForAnnotation(aWindow, false);
+ },
+
+ /**
+ * Show an info panel.
+ *
+ * @param {ChromeWindow} aChromeWindow
+ * @param {Node} aAnchor
+ * @param {String} [aTitle=""]
+ * @param {String} [aDescription=""]
+ * @param {String} [aIconURL=""]
+ * @param {Object[]} [aButtons=[]]
+ * @param {Object} [aOptions={}]
+ * @param {String} [aOptions.closeButtonCallback]
+ * @param {String} [aOptions.targetCallback]
+ */
+ async showInfo(
+ aChromeWindow,
+ aAnchor,
+ aTitle = "",
+ aDescription = "",
+ aIconURL = "",
+ aButtons = [],
+ aOptions = {}
+ ) {
+ let showInfoElement = aAnchorEl => {
+ aAnchorEl.focus();
+
+ let document = aChromeWindow.document;
+ let tooltip = this.getTooltipAndMaybeCreate(document);
+ let tooltipTitle = document.getElementById("UITourTooltipTitle");
+ let tooltipDesc = document.getElementById("UITourTooltipDescription");
+ let tooltipIcon = document.getElementById("UITourTooltipIcon");
+ let tooltipButtons = document.getElementById("UITourTooltipButtons");
+
+ if (tooltip.state == "showing" || tooltip.state == "open") {
+ tooltip.hidePopup();
+ }
+
+ tooltipTitle.textContent = aTitle || "";
+ tooltipDesc.textContent = aDescription || "";
+ tooltipIcon.src = aIconURL || "";
+ tooltipIcon.hidden = !aIconURL;
+
+ while (tooltipButtons.firstChild) {
+ tooltipButtons.firstChild.remove();
+ }
+
+ for (let button of aButtons) {
+ let isButton = button.style != "text";
+ let el = document.createXULElement(isButton ? "button" : "label");
+ el.setAttribute(isButton ? "label" : "value", button.label);
+
+ if (isButton) {
+ if (button.iconURL) {
+ el.setAttribute("image", button.iconURL);
+ }
+
+ if (button.style == "link") {
+ el.setAttribute("class", "button-link");
+ }
+
+ if (button.style == "primary") {
+ el.setAttribute("class", "button-primary");
+ }
+
+ // Don't close the popup or call the callback for style=text as they
+ // aren't links/buttons.
+ let callback = button.callback;
+ el.addEventListener("command", event => {
+ tooltip.hidePopup();
+ callback(event);
+ });
+ }
+
+ tooltipButtons.appendChild(el);
+ }
+
+ tooltipButtons.hidden = !aButtons.length;
+
+ let tooltipClose = document.getElementById("UITourTooltipClose");
+ let closeButtonCallback = event => {
+ this.hideInfo(document.defaultView);
+ if (aOptions && aOptions.closeButtonCallback) {
+ aOptions.closeButtonCallback();
+ }
+ };
+ tooltipClose.addEventListener("command", closeButtonCallback);
+
+ let targetCallback = event => {
+ let details = {
+ target: aAnchor.targetName,
+ type: event.type,
+ };
+ aOptions.targetCallback(details);
+ };
+ if (aOptions.targetCallback && aAnchor.addTargetListener) {
+ aAnchor.addTargetListener(document, targetCallback);
+ }
+
+ tooltip.addEventListener(
+ "popuphiding",
+ function (event) {
+ tooltipClose.removeEventListener("command", closeButtonCallback);
+ if (aOptions.targetCallback && aAnchor.removeTargetListener) {
+ aAnchor.removeTargetListener(document, targetCallback);
+ }
+ },
+ { once: true }
+ );
+
+ tooltip.setAttribute("targetName", aAnchor.targetName);
+
+ let alignment = "bottomright topright";
+ if (aAnchor.infoPanelPosition) {
+ alignment = aAnchor.infoPanelPosition;
+ }
+
+ let { infoPanelOffsetX: xOffset, infoPanelOffsetY: yOffset } = aAnchor;
+
+ this._addAnnotationPanelMutationObserver(tooltip);
+ tooltip.openPopup(aAnchorEl, alignment, xOffset || 0, yOffset || 0);
+ if (tooltip.state == "closed") {
+ document.defaultView.addEventListener(
+ "endmodalstate",
+ function () {
+ tooltip.openPopup(aAnchorEl, alignment);
+ },
+ { once: true }
+ );
+ }
+ };
+
+ try {
+ await this._ensureTarget(aChromeWindow, aAnchor);
+ let anchorEl = await this._correctAnchor(aChromeWindow, aAnchor);
+ showInfoElement(anchorEl);
+ } catch (e) {
+ lazy.log.warn(e);
+ }
+ },
+
+ getHighlightContainerAndMaybeCreate(document) {
+ let highlightContainer = document.getElementById(
+ "UITourHighlightContainer"
+ );
+ if (!highlightContainer) {
+ let wrapper = document.getElementById("UITourHighlightTemplate");
+ wrapper.replaceWith(wrapper.content);
+ highlightContainer = document.getElementById("UITourHighlightContainer");
+ }
+
+ return highlightContainer;
+ },
+
+ getTooltipAndMaybeCreate(document) {
+ let tooltip = document.getElementById("UITourTooltip");
+ if (!tooltip) {
+ let wrapper = document.getElementById("UITourTooltipTemplate");
+ wrapper.replaceWith(wrapper.content);
+ tooltip = document.getElementById("UITourTooltip");
+ }
+ return tooltip;
+ },
+
+ getHighlightAndMaybeCreate(document) {
+ let highlight = document.getElementById("UITourHighlight");
+ if (!highlight) {
+ let wrapper = document.getElementById("UITourHighlightTemplate");
+ wrapper.replaceWith(wrapper.content);
+ highlight = document.getElementById("UITourHighlight");
+ }
+ return highlight;
+ },
+
+ isInfoOnTarget(aChromeWindow, aTargetName) {
+ let document = aChromeWindow.document;
+ let tooltip = this.getTooltipAndMaybeCreate(document);
+ return (
+ tooltip.getAttribute("targetName") == aTargetName &&
+ tooltip.state != "closed"
+ );
+ },
+
+ _hideInfoElement(aWindow) {
+ let document = aWindow.document;
+ let tooltip = this.getTooltipAndMaybeCreate(document);
+ this._removeAnnotationPanelMutationObserver(tooltip);
+ tooltip.hidePopup();
+ let tooltipButtons = document.getElementById("UITourTooltipButtons");
+ while (tooltipButtons.firstChild) {
+ tooltipButtons.firstChild.remove();
+ }
+ },
+
+ hideInfo(aWindow) {
+ this._hideInfoElement(aWindow);
+ this._setMenuStateForAnnotation(aWindow, false);
+ },
+
+ showMenu(aWindow, aMenuName, aOpenCallback = null, aOptions = {}) {
+ lazy.log.debug("showMenu:", aMenuName);
+ function openMenuButton(aMenuBtn) {
+ if (!aMenuBtn || !aMenuBtn.hasMenu() || aMenuBtn.open) {
+ if (aOpenCallback) {
+ aOpenCallback();
+ }
+ return;
+ }
+ if (aOpenCallback) {
+ aMenuBtn.addEventListener("popupshown", aOpenCallback, { once: true });
+ }
+ aMenuBtn.openMenu(true);
+ }
+
+ if (aMenuName == "appMenu") {
+ let menu = {
+ onPanelHidden: this.onPanelHidden,
+ };
+ menu.node = aWindow.PanelUI.panel;
+ menu.onPopupHiding = this.onAppMenuHiding;
+ menu.onViewShowing = this.onAppMenuSubviewShowing;
+ menu.show = () => aWindow.PanelUI.show();
+
+ if (!aOptions.autohide) {
+ menu.node.setAttribute("noautohide", "true");
+ }
+ // If the popup is already opened, don't recreate the widget as it may cause a flicker.
+ if (menu.node.state != "open") {
+ this.recreatePopup(menu.node);
+ }
+ if (aOpenCallback) {
+ menu.node.addEventListener("popupshown", aOpenCallback, { once: true });
+ }
+ menu.node.addEventListener("popuphidden", menu.onPanelHidden);
+ menu.node.addEventListener("popuphiding", menu.onPopupHiding);
+ menu.node.addEventListener("ViewShowing", menu.onViewShowing);
+ menu.show();
+ } else if (aMenuName == "bookmarks") {
+ let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
+ openMenuButton(menuBtn);
+ } else if (aMenuName == "pocket") {
+ let button = aWindow.document.getElementById("save-to-pocket-button");
+ if (!button) {
+ lazy.log.error("Can't open the pocket menu without a button");
+ return;
+ }
+ aWindow.document.addEventListener("ViewShown", aOpenCallback, {
+ once: true,
+ });
+ button.click();
+ } else if (aMenuName == "urlbar") {
+ let urlbar = aWindow.gURLBar;
+ if (aOpenCallback) {
+ urlbar.panel.addEventListener("popupshown", aOpenCallback, {
+ once: true,
+ });
+ }
+ urlbar.focus();
+ // To demonstrate the ability of searching, we type "Firefox" in advance
+ // for URLBar's dropdown. To limit the search results on browser-related
+ // items, we use "Firefox" hard-coded rather than l10n brandShortName
+ // entity to avoid unrelated or unpredicted results for, like, Nightly
+ // or translated entites.
+ const SEARCH_STRING = "Firefox";
+ urlbar.value = SEARCH_STRING;
+ urlbar.select();
+ urlbar.startQuery({
+ searchString: SEARCH_STRING,
+ allowAutofill: false,
+ });
+ }
+ },
+
+ hideMenu(aWindow, aMenuName) {
+ lazy.log.debug("hideMenu:", aMenuName);
+ function closeMenuButton(aMenuBtn) {
+ if (aMenuBtn && aMenuBtn.hasMenu()) {
+ aMenuBtn.openMenu(false);
+ }
+ }
+
+ if (aMenuName == "appMenu") {
+ aWindow.PanelUI.hide();
+ } else if (aMenuName == "bookmarks") {
+ let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
+ closeMenuButton(menuBtn);
+ } else if (aMenuName == "urlbar") {
+ aWindow.gURLBar.view.close();
+ }
+ },
+
+ showNewTab(aWindow, aBrowser) {
+ aWindow.gURLBar.focus();
+ let url = "about:newtab";
+ aWindow.openLinkIn(url, "current", {
+ targetBrowser: aBrowser,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {}
+ ),
+ });
+ },
+
+ showProtectionReport(aWindow, aBrowser) {
+ let url = "about:protections";
+ aWindow.openLinkIn(url, "current", {
+ targetBrowser: aBrowser,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {}
+ ),
+ });
+ },
+
+ _hideAnnotationsForPanel(aEvent, aShouldClosePanel, aTargetPositionCallback) {
+ let win = aEvent.target.ownerGlobal;
+ let hideHighlightMethod = null;
+ let hideInfoMethod = null;
+ if (aShouldClosePanel) {
+ hideHighlightMethod = aWin => this.hideHighlight(aWin);
+ hideInfoMethod = aWin => this.hideInfo(aWin);
+ } else {
+ // Don't have to close panel, let's only hide annotation elements
+ hideHighlightMethod = aWin => this._hideHighlightElement(aWin);
+ hideInfoMethod = aWin => this._hideInfoElement(aWin);
+ }
+ let annotationElements = new Map([
+ // [annotationElement (panel), method to hide the annotation]
+ [
+ this.getHighlightContainerAndMaybeCreate(win.document),
+ hideHighlightMethod,
+ ],
+ [this.getTooltipAndMaybeCreate(win.document), hideInfoMethod],
+ ]);
+ annotationElements.forEach((hideMethod, annotationElement) => {
+ if (annotationElement.state != "closed") {
+ let targetName = annotationElement.getAttribute("targetName");
+ UITour.getTarget(win, targetName)
+ .then(aTarget => {
+ // Since getTarget is async, we need to make sure that the target hasn't
+ // changed since it may have just moved to somewhere outside of the app menu.
+ if (
+ annotationElement.getAttribute("targetName") !=
+ aTarget.targetName ||
+ annotationElement.state == "closed" ||
+ !aTargetPositionCallback(aTarget)
+ ) {
+ return;
+ }
+ hideMethod(win);
+ })
+ .catch(lazy.log.error);
+ }
+ });
+ },
+
+ onAppMenuHiding(aEvent) {
+ UITour._hideAnnotationsForPanel(aEvent, true, UITour.targetIsInAppMenu);
+ },
+
+ onAppMenuSubviewShowing(aEvent) {
+ UITour._hideAnnotationsForPanel(aEvent, false, UITour.targetIsInAppMenu);
+ },
+
+ onPanelHidden(aEvent) {
+ aEvent.target.removeAttribute("noautohide");
+ UITour.recreatePopup(aEvent.target);
+ UITour.clearAvailableTargetsCache();
+ },
+
+ recreatePopup(aPanel) {
+ // After changing popup attributes that relate to how the native widget is created
+ // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect.
+ if (aPanel.hidden) {
+ // If the panel is already hidden, we don't need to recreate it but flush
+ // in case someone just hid it.
+ aPanel.clientWidth; // flush
+ return;
+ }
+ aPanel.hidden = true;
+ aPanel.clientWidth; // flush
+ aPanel.hidden = false;
+ },
+
+ getConfiguration(aBrowser, aWindow, aConfiguration, aCallbackID) {
+ switch (aConfiguration) {
+ case "appinfo":
+ this.getAppInfo(aBrowser, aWindow, aCallbackID);
+ break;
+ case "availableTargets":
+ this.getAvailableTargets(aBrowser, aWindow, aCallbackID);
+ break;
+ case "colorway":
+ this.sendPageCallback(aBrowser, aCallbackID, lazy.COLORWAY_IDS);
+ break;
+ case "search":
+ case "selectedSearchEngine":
+ Services.search
+ .getVisibleEngines()
+ .then(engines => {
+ this.sendPageCallback(aBrowser, aCallbackID, {
+ searchEngineIdentifier: Services.search.defaultEngine.identifier,
+ engines: engines
+ .filter(engine => engine.identifier)
+ .map(engine => TARGET_SEARCHENGINE_PREFIX + engine.identifier),
+ });
+ })
+ .catch(() => {
+ this.sendPageCallback(aBrowser, aCallbackID, {
+ engines: [],
+ searchEngineIdentifier: "",
+ });
+ });
+ break;
+ case "fxa":
+ this.getFxA(aBrowser, aCallbackID);
+ break;
+ case "fxaConnections":
+ this.getFxAConnections(aBrowser, aCallbackID);
+ break;
+
+ // NOTE: 'sync' is deprecated and should be removed in Firefox 73 (because
+ // by then, all consumers will have upgraded to use 'fxa' in that version
+ // and later.)
+ case "sync":
+ this.sendPageCallback(aBrowser, aCallbackID, {
+ setup: Services.prefs.prefHasUserValue("services.sync.username"),
+ desktopDevices: Services.prefs.getIntPref(
+ "services.sync.clients.devices.desktop",
+ 0
+ ),
+ mobileDevices: Services.prefs.getIntPref(
+ "services.sync.clients.devices.mobile",
+ 0
+ ),
+ totalDevices: Services.prefs.getIntPref(
+ "services.sync.numClients",
+ 0
+ ),
+ });
+ break;
+ case "canReset":
+ this.sendPageCallback(
+ aBrowser,
+ aCallbackID,
+ lazy.ResetProfile.resetSupported()
+ );
+ break;
+ default:
+ lazy.log.error(
+ "getConfiguration: Unknown configuration requested: " + aConfiguration
+ );
+ break;
+ }
+ },
+
+ async setConfiguration(aWindow, aConfiguration, aValue) {
+ switch (aConfiguration) {
+ case "defaultBrowser":
+ // Ignore aValue in this case because the default browser can only
+ // be set, not unset.
+ try {
+ let shell = aWindow.getShellService();
+ if (shell) {
+ shell.setDefaultBrowser(true, false);
+ }
+ } catch (e) {}
+ break;
+ case "colorway":
+ // Potentially revert to a previous theme.
+ let toEnable = this._prevTheme;
+
+ // Activate the allowed colorway.
+ if (lazy.COLORWAY_IDS.includes(aValue)) {
+ // Save the previous theme if this is the first activation.
+ if (!this._prevTheme) {
+ this._prevTheme = (
+ await lazy.AddonManager.getAddonsByTypes(["theme"])
+ ).find(theme => theme.isActive);
+ }
+ toEnable = await lazy.AddonManager.getAddonByID(aValue);
+ }
+ toEnable?.enable();
+ break;
+ default:
+ lazy.log.error(
+ "setConfiguration: Unknown configuration requested: " + aConfiguration
+ );
+ break;
+ }
+ },
+
+ // Get data about the local FxA account. This should be a low-latency request
+ // - everything returned here can be obtained locally without hitting any
+ // remote servers. See also `getFxAConnections()`
+ getFxA(aBrowser, aCallbackID) {
+ (async () => {
+ let setup = !!(await lazy.fxAccounts.getSignedInUser());
+ let result = { setup };
+ if (!setup) {
+ this.sendPageCallback(aBrowser, aCallbackID, result);
+ return;
+ }
+ // We are signed in so need to build a richer result.
+ // Each of the "browser services" - currently only "sync" is supported
+ result.browserServices = {};
+ let hasSync = Services.prefs.prefHasUserValue("services.sync.username");
+ if (hasSync) {
+ result.browserServices.sync = {
+ // We always include 'setup' for b/w compatibility.
+ setup: true,
+ desktopDevices: Services.prefs.getIntPref(
+ "services.sync.clients.devices.desktop",
+ 0
+ ),
+ mobileDevices: Services.prefs.getIntPref(
+ "services.sync.clients.devices.mobile",
+ 0
+ ),
+ totalDevices: Services.prefs.getIntPref(
+ "services.sync.numClients",
+ 0
+ ),
+ };
+ }
+ // And the account state.
+ result.accountStateOK = await lazy.fxAccounts.hasLocalSession();
+ this.sendPageCallback(aBrowser, aCallbackID, result);
+ })().catch(err => {
+ lazy.log.error(err);
+ this.sendPageCallback(aBrowser, aCallbackID, {});
+ });
+ },
+
+ // Get data about the FxA account "connections" (ie, other devices, other
+ // apps, etc. Note that this is likely to be a high-latency request - we will
+ // usually hit the FxA servers to obtain this info.
+ getFxAConnections(aBrowser, aCallbackID) {
+ (async () => {
+ let setup = !!(await lazy.fxAccounts.getSignedInUser());
+ let result = { setup };
+ if (!setup) {
+ this.sendPageCallback(aBrowser, aCallbackID, result);
+ return;
+ }
+ // We are signed in so need to build a richer result.
+ let devices = lazy.fxAccounts.device.recentDeviceList;
+ // A recent device list is fine, but if we don't even have that we should
+ // wait for it to be fetched.
+ if (!devices) {
+ try {
+ await lazy.fxAccounts.device.refreshDeviceList();
+ } catch (ex) {
+ lazy.log.warn("failed to fetch device list", ex);
+ }
+ devices = lazy.fxAccounts.device.recentDeviceList;
+ }
+ if (devices) {
+ // A falsey `devices` should be impossible, so we omit `devices` from
+ // the result object so the consuming page can try to differentiate
+ // between "no additional devices" and "something's wrong"
+ result.numOtherDevices = Math.max(0, devices.length - 1);
+ result.numDevicesByType = devices
+ .filter(d => !d.isCurrentDevice)
+ .reduce((accum, d) => {
+ let type = d.type || "unknown";
+ accum[type] = (accum[type] || 0) + 1;
+ return accum;
+ }, {});
+ }
+
+ try {
+ // Each of the "account services", which we turn into a map keyed by ID.
+ let attachedClients = await lazy.fxAccounts.listAttachedOAuthClients();
+ result.accountServices = attachedClients
+ .filter(c => !!c.id)
+ .reduce((accum, c) => {
+ accum[c.id] = {
+ id: c.id,
+ lastAccessedWeeksAgo: c.lastAccessedDaysAgo
+ ? Math.floor(c.lastAccessedDaysAgo / 7)
+ : null,
+ };
+ return accum;
+ }, {});
+ } catch (ex) {
+ lazy.log.warn("Failed to build the attached clients list", ex);
+ }
+ this.sendPageCallback(aBrowser, aCallbackID, result);
+ })().catch(err => {
+ lazy.log.error(err);
+ this.sendPageCallback(aBrowser, aCallbackID, {});
+ });
+ },
+
+ getAppInfo(aBrowser, aWindow, aCallbackID) {
+ (async () => {
+ let appinfo = { version: Services.appinfo.version };
+
+ // Identifier of the partner repack, as stored in preference "distribution.id"
+ // and included in Firefox and other update pings. Note this is not the same as
+ // Services.appinfo.distributionID (value of MOZ_DISTRIBUTION_ID is set at build time).
+ let distribution = Services.prefs
+ .getDefaultBranch("distribution.")
+ .getCharPref("id", "default");
+ appinfo.distribution = distribution;
+
+ // Update channel, in a way that preserves 'beta' for RC beta builds:
+ appinfo.defaultUpdateChannel = lazy.UpdateUtils.getUpdateChannel(
+ false /* no partner ID */
+ );
+
+ let isDefaultBrowser = null;
+ try {
+ let shell = aWindow.getShellService();
+ if (shell) {
+ isDefaultBrowser = shell.isDefaultBrowser(false);
+ }
+ } catch (e) {}
+ appinfo.defaultBrowser = isDefaultBrowser;
+
+ let canSetDefaultBrowserInBackground = true;
+ if (
+ AppConstants.isPlatformAndVersionAtLeast("win", "6.2") ||
+ AppConstants.isPlatformAndVersionAtLeast("macosx", "10.10")
+ ) {
+ canSetDefaultBrowserInBackground = false;
+ } else if (AppConstants.platform == "linux") {
+ // The ShellService may not exist on some versions of Linux.
+ try {
+ aWindow.getShellService();
+ } catch (e) {
+ canSetDefaultBrowserInBackground = null;
+ }
+ }
+
+ appinfo.canSetDefaultBrowserInBackground =
+ canSetDefaultBrowserInBackground;
+
+ // Expose Profile creation and last reset dates in weeks.
+ const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
+ let profileAge = await lazy.ProfileAge();
+ let createdDate = await profileAge.created;
+ let resetDate = await profileAge.reset;
+ let createdWeeksAgo = Math.floor((Date.now() - createdDate) / ONE_WEEK);
+ let resetWeeksAgo = null;
+ if (resetDate) {
+ resetWeeksAgo = Math.floor((Date.now() - resetDate) / ONE_WEEK);
+ }
+ appinfo.profileCreatedWeeksAgo = createdWeeksAgo;
+ appinfo.profileResetWeeksAgo = resetWeeksAgo;
+
+ this.sendPageCallback(aBrowser, aCallbackID, appinfo);
+ })().catch(err => {
+ lazy.log.error(err);
+ this.sendPageCallback(aBrowser, aCallbackID, {});
+ });
+ },
+
+ getAvailableTargets(aBrowser, aChromeWindow, aCallbackID) {
+ (async () => {
+ let window = aChromeWindow;
+ let data = this.availableTargetsCache.get(window);
+ if (data) {
+ lazy.log.debug(
+ "getAvailableTargets: Using cached targets list",
+ data.targets.join(",")
+ );
+ this.sendPageCallback(aBrowser, aCallbackID, data);
+ return;
+ }
+
+ let promises = [];
+ for (let targetName of this.targets.keys()) {
+ promises.push(this.getTarget(window, targetName));
+ }
+ let targetObjects = await Promise.all(promises);
+
+ let targetNames = [];
+ for (let targetObject of targetObjects) {
+ if (targetObject.node) {
+ targetNames.push(targetObject.targetName);
+ }
+ }
+
+ data = {
+ targets: targetNames,
+ };
+ this.availableTargetsCache.set(window, data);
+ this.sendPageCallback(aBrowser, aCallbackID, data);
+ })().catch(err => {
+ lazy.log.error(err);
+ this.sendPageCallback(aBrowser, aCallbackID, {
+ targets: [],
+ });
+ });
+ },
+
+ addNavBarWidget(aTarget, aBrowser, aCallbackID) {
+ if (aTarget.node) {
+ lazy.log.error(
+ "addNavBarWidget: can't add a widget already present:",
+ aTarget
+ );
+ return;
+ }
+ if (!aTarget.allowAdd) {
+ lazy.log.error(
+ "addNavBarWidget: not allowed to add this widget:",
+ aTarget
+ );
+ return;
+ }
+ if (!aTarget.widgetName) {
+ lazy.log.error(
+ "addNavBarWidget: can't add a widget without a widgetName property:",
+ aTarget
+ );
+ return;
+ }
+
+ lazy.CustomizableUI.addWidgetToArea(
+ aTarget.widgetName,
+ lazy.CustomizableUI.AREA_NAVBAR
+ );
+ lazy.BrowserUsageTelemetry.recordWidgetChange(
+ aTarget.widgetName,
+ lazy.CustomizableUI.AREA_NAVBAR,
+ "uitour"
+ );
+ this.sendPageCallback(aBrowser, aCallbackID);
+ },
+
+ _addAnnotationPanelMutationObserver(aPanelEl) {
+ if (AppConstants.platform == "linux") {
+ let observer = this._annotationPanelMutationObservers.get(aPanelEl);
+ if (observer) {
+ return;
+ }
+ let win = aPanelEl.ownerGlobal;
+ observer = new win.MutationObserver(this._annotationMutationCallback);
+ this._annotationPanelMutationObservers.set(aPanelEl, observer);
+ let observerOptions = {
+ attributeFilter: ["height", "width"],
+ attributes: true,
+ };
+ observer.observe(aPanelEl, observerOptions);
+ }
+ },
+
+ _removeAnnotationPanelMutationObserver(aPanelEl) {
+ if (AppConstants.platform == "linux") {
+ let observer = this._annotationPanelMutationObservers.get(aPanelEl);
+ if (observer) {
+ observer.disconnect();
+ this._annotationPanelMutationObservers.delete(aPanelEl);
+ }
+ }
+ },
+
+ /**
+ * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to
+ * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting
+ * set on the panel.
+ */
+ _annotationMutationCallback(aMutations) {
+ for (let mutation of aMutations) {
+ // Remove both attributes at once and ignore remaining mutations to be proccessed.
+ mutation.target.removeAttribute("width");
+ mutation.target.removeAttribute("height");
+ return;
+ }
+ },
+
+ selectSearchEngine(aID) {
+ return new Promise((resolve, reject) => {
+ Services.search.getVisibleEngines().then(engines => {
+ for (let engine of engines) {
+ if (engine.identifier == aID) {
+ Services.search
+ .setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UITOUR)
+ .finally(resolve);
+ return;
+ }
+ }
+ reject("selectSearchEngine could not find engine with given ID");
+ });
+ });
+ },
+
+ notify(eventName, params) {
+ for (let window of Services.wm.getEnumerator("navigator:browser")) {
+ if (window.closed) {
+ continue;
+ }
+
+ let openTourBrowsers = this.tourBrowsersByWindow.get(window);
+ if (!openTourBrowsers) {
+ continue;
+ }
+
+ for (let browser of openTourBrowsers) {
+ let detail = {
+ event: eventName,
+ params,
+ };
+ let contextToVisit = browser.browsingContext;
+ let global = contextToVisit.currentWindowGlobal;
+ let actor = global.getActor("UITour");
+ actor.sendAsyncMessage("UITour:SendPageNotification", detail);
+ }
+ }
+ },
+};
+
+UITour.init();
+
+/**
+ * UITour Health Report
+ */
+/**
+ * Public API to be called by the UITour code
+ */
+const UITourHealthReport = {
+ recordTreatmentTag(tag, value) {
+ return lazy.TelemetryController.submitExternalPing(
+ "uitour-tag",
+ {
+ version: 1,
+ tagName: tag,
+ tagValue: value,
+ },
+ {
+ addClientId: true,
+ addEnvironment: true,
+ }
+ );
+ },
+};
diff --git a/browser/components/uitour/UITourChild.sys.mjs b/browser/components/uitour/UITourChild.sys.mjs
new file mode 100644
index 0000000000..65ef20931b
--- /dev/null
+++ b/browser/components/uitour/UITourChild.sys.mjs
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const PREF_TEST_WHITELIST = "browser.uitour.testingOrigins";
+const UITOUR_PERMISSION = "uitour";
+
+export class UITourChild extends JSWindowActorChild {
+ handleEvent(event) {
+ if (!Services.prefs.getBoolPref("browser.uitour.enabled")) {
+ return;
+ }
+ if (!this.ensureTrustedOrigin()) {
+ return;
+ }
+
+ this.sendAsyncMessage("UITour:onPageEvent", {
+ detail: event.detail,
+ type: event.type,
+ pageVisibilityState: this.document.visibilityState,
+ });
+ }
+
+ isTestingOrigin(aURI) {
+ if (
+ Services.prefs.getPrefType(PREF_TEST_WHITELIST) !=
+ Services.prefs.PREF_STRING
+ ) {
+ return false;
+ }
+
+ // Add any testing origins (comma-seperated) to the whitelist for the session.
+ for (let origin of Services.prefs
+ .getCharPref(PREF_TEST_WHITELIST)
+ .split(",")) {
+ try {
+ let testingURI = Services.io.newURI(origin);
+ if (aURI.prePath == testingURI.prePath) {
+ return true;
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ return false;
+ }
+
+ // This function is copied from UITour.sys.mjs.
+ isSafeScheme(aURI) {
+ let allowedSchemes = new Set(["https", "about"]);
+ if (!Services.prefs.getBoolPref("browser.uitour.requireSecure")) {
+ allowedSchemes.add("http");
+ }
+
+ if (!allowedSchemes.has(aURI.scheme)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ ensureTrustedOrigin() {
+ if (this.browsingContext.top != this.browsingContext) {
+ return false;
+ }
+
+ let uri = this.document.documentURIObject;
+
+ if (uri.schemeIs("chrome")) {
+ return true;
+ }
+
+ if (!this.isSafeScheme(uri)) {
+ return false;
+ }
+
+ let principal = Services.scriptSecurityManager.principalWithOA(
+ this.document.nodePrincipal,
+ {}
+ );
+ let permission = Services.perms.testPermissionFromPrincipal(
+ principal,
+ UITOUR_PERMISSION
+ );
+ if (permission == Services.perms.ALLOW_ACTION) {
+ return true;
+ }
+
+ // Bug 1557153: To allow Skyline messaging, workaround for UNKNOWN_ACTION
+ // overriding browser/app/permissions default
+ // Bug 1837407: Do a similar thing for support.mozilla.org for the same
+ // underlying issue (bug 1579517).
+ return (
+ uri.host == "www.mozilla.org" ||
+ uri.host == "support.mozilla.org" ||
+ this.isTestingOrigin(uri)
+ );
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "UITour:SendPageCallback":
+ this.sendPageEvent("Response", aMessage.data);
+ break;
+ case "UITour:SendPageNotification":
+ this.sendPageEvent("Notification", aMessage.data);
+ break;
+ }
+ }
+
+ sendPageEvent(type, detail) {
+ if (!this.ensureTrustedOrigin()) {
+ return;
+ }
+
+ let win = this.contentWindow;
+ let eventName = "mozUITour" + type;
+ let event = new win.CustomEvent(eventName, {
+ bubbles: true,
+ detail: Cu.cloneInto(detail, win),
+ });
+ win.document.dispatchEvent(event);
+ }
+}
diff --git a/browser/components/uitour/UITourParent.sys.mjs b/browser/components/uitour/UITourParent.sys.mjs
new file mode 100644
index 0000000000..f603717638
--- /dev/null
+++ b/browser/components/uitour/UITourParent.sys.mjs
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { UITour } from "resource:///modules/UITour.sys.mjs";
+
+export class UITourParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ switch (message.name) {
+ case "UITour:onPageEvent":
+ if (this.manager.rootFrameLoader) {
+ let browser = this.manager.rootFrameLoader.ownerElement;
+ UITour.onPageEvent(message.data, browser);
+ break;
+ }
+ }
+ }
+}
diff --git a/browser/components/uitour/docs/UITour-lib.rst b/browser/components/uitour/docs/UITour-lib.rst
new file mode 100644
index 0000000000..7f12956808
--- /dev/null
+++ b/browser/components/uitour/docs/UITour-lib.rst
@@ -0,0 +1,11 @@
+UITour library API
+==================
+
+This is the web API provided by the library UITour-lib.js for use by web content with appropriate permissions.
+
+.. js:autoclass:: Mozilla.UITour
+ :members:
+ :exclude-members: Configuration
+
+.. js:autoclass:: Mozilla.UITour.Configuration
+ :members:
diff --git a/browser/components/uitour/docs/index.rst b/browser/components/uitour/docs/index.rst
new file mode 100644
index 0000000000..95e1fcec1a
--- /dev/null
+++ b/browser/components/uitour/docs/index.rst
@@ -0,0 +1,8 @@
+UITour
+======
+
+.. toctree::
+ UITour-lib
+
+.. js:autoclass:: Mozilla.UITour
+ :members: none
diff --git a/browser/components/uitour/moz.build b/browser/components/uitour/moz.build
new file mode 100644
index 0000000000..194e01cdd6
--- /dev/null
+++ b/browser/components/uitour/moz.build
@@ -0,0 +1,14 @@
+# 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/.
+
+EXTRA_JS_MODULES += ["UITour.sys.mjs", "UITourChild.sys.mjs", "UITourParent.sys.mjs"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser.ini",
+]
+
+SPHINX_TREES["docs"] = "docs"
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Tours")
diff --git a/browser/components/uitour/test/browser.ini b/browser/components/uitour/test/browser.ini
new file mode 100644
index 0000000000..2fb34ea080
--- /dev/null
+++ b/browser/components/uitour/test/browser.ini
@@ -0,0 +1,49 @@
+[DEFAULT]
+support-files =
+ head.js
+ image.png
+ uitour.html
+ ../UITour-lib.js
+
+
+[browser_UITour.js]
+skip-if =
+ os == "linux" || verify # Intermittent failures, bug 951965
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_UITour2.js]
+[browser_UITour3.js]
+[browser_UITour4.js]
+[browser_UITour5.js]
+[browser_UITour_annotation_size_attributes.js]
+[browser_UITour_availableTargets.js]
+[browser_UITour_colorway.js]
+[browser_UITour_defaultBrowser.js]
+[browser_UITour_detach_tab.js]
+[browser_UITour_forceReaderMode.js]
+[browser_UITour_modalDialog.js]
+skip-if = os != "mac" # modal dialog disabling only working on OS X.
+[browser_UITour_observe.js]
+[browser_UITour_panel_close_annotation.js]
+skip-if = true # Bug 1026310
+[browser_UITour_pocket.js]
+skip-if = true # Disabled pending removal of pocket UI Tour
+[browser_UITour_resetProfile.js]
+skip-if = (verify && !debug && (os == 'linux'))
+[browser_UITour_showNewTab.js]
+skip-if = (verify && !debug && (os == 'linux'))
+[browser_UITour_showProtectionReport.js]
+skip-if = os == "linux" && (asan || debug || tsan) # Bug 1697217
+[browser_UITour_sync.js]
+skip-if =
+ os == "linux" # Bug 1678417
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_UITour_toggleReaderMode.js]
+skip-if = (verify && !debug && (os == 'linux'))
+[browser_backgroundTab.js]
+[browser_closeTab.js]
+skip-if = (verify && !debug && (os == 'linux'))
+[browser_fxa.js]
+[browser_fxa_config.js]
+[browser_openPreferences.js]
+[browser_openSearchPanel.js]
+skip-if = true # Bug 1113038 - Intermittent "Popup was opened"
diff --git a/browser/components/uitour/test/browser_UITour.js b/browser/components/uitour/test/browser_UITour.js
new file mode 100644
index 0000000000..fc2353117a
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour.js
@@ -0,0 +1,751 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+ChromeUtils.defineESModuleGetters(this, {
+ ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
+ TelemetryArchiveTesting:
+ "resource://testing-common/TelemetryArchiveTesting.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ function test_untrusted_host(done) {
+ loadUITourTestPage(function () {
+ CustomizableUI.addWidgetToArea(
+ "bookmarks-menu-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("bookmarks-menu-button")
+ );
+ let bookmarksMenu = document.getElementById("bookmarks-menu-button");
+ is(bookmarksMenu.open, false, "Bookmark menu should initially be closed");
+
+ gContentAPI.showMenu("bookmarks");
+ is(
+ bookmarksMenu.open,
+ false,
+ "Bookmark menu should not open on a untrusted host"
+ );
+
+ done();
+ }, "http://mochi.test:8888/");
+ },
+ function test_testing_host(done) {
+ // Add two testing origins intentionally surrounded by whitespace to be ignored.
+ Services.prefs.setCharPref(
+ "browser.uitour.testingOrigins",
+ "https://test1.example.org, https://test2.example.org:443 "
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.uitour.testingOrigins");
+ });
+ function callback(result) {
+ ok(result, "Callback should be called on a testing origin");
+ done();
+ }
+
+ loadUITourTestPage(function () {
+ gContentAPI.getConfiguration("appinfo", callback);
+ }, "https://test2.example.org/");
+ },
+ function test_unsecure_host(done) {
+ loadUITourTestPage(function () {
+ let bookmarksMenu = document.getElementById("bookmarks-menu-button");
+ is(bookmarksMenu.open, false, "Bookmark menu should initially be closed");
+
+ gContentAPI.showMenu("bookmarks");
+ is(
+ bookmarksMenu.open,
+ false,
+ "Bookmark menu should not open on a unsecure host"
+ );
+
+ done();
+ }, "http://example.org/");
+ },
+ function test_unsecure_host_override(done) {
+ Services.prefs.setBoolPref("browser.uitour.requireSecure", false);
+ loadUITourTestPage(function () {
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ gContentAPI.showHighlight("urlbar").then(() => {
+ waitForElementToBeVisible(
+ highlight,
+ done,
+ "Highlight should be shown on a unsecure host when override pref is set"
+ );
+
+ Services.prefs.setBoolPref("browser.uitour.requireSecure", true);
+ });
+ }, "http://example.org/");
+ },
+ function test_disabled(done) {
+ Services.prefs.setBoolPref("browser.uitour.enabled", false);
+
+ let bookmarksMenu = document.getElementById("bookmarks-menu-button");
+ is(bookmarksMenu.open, false, "Bookmark menu should initially be closed");
+
+ gContentAPI.showMenu("bookmarks").then(() => {
+ is(
+ bookmarksMenu.open,
+ false,
+ "Bookmark menu should not open when feature is disabled"
+ );
+
+ Services.prefs.setBoolPref("browser.uitour.enabled", true);
+ });
+ done();
+ },
+ function test_highlight(done) {
+ function test_highlight_2() {
+ let highlight = document.getElementById("UITourHighlight");
+ gContentAPI.hideHighlight();
+
+ waitForElementToBeHidden(
+ highlight,
+ test_highlight_3,
+ "Highlight should be hidden after hideHighlight()"
+ );
+ }
+ function test_highlight_3() {
+ is_element_hidden(
+ highlight,
+ "Highlight should be hidden after hideHighlight()"
+ );
+
+ gContentAPI.showHighlight("urlbar");
+ waitForElementToBeVisible(
+ highlight,
+ test_highlight_4,
+ "Highlight should be shown after showHighlight()"
+ );
+ }
+ function test_highlight_4() {
+ let highlight = document.getElementById("UITourHighlight");
+ gContentAPI.showHighlight("backForward");
+ waitForElementToBeVisible(
+ highlight,
+ done,
+ "Highlight should be shown after showHighlight()"
+ );
+ }
+
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ gContentAPI.showHighlight("urlbar");
+ waitForElementToBeVisible(
+ highlight,
+ test_highlight_2,
+ "Highlight should be shown after showHighlight()"
+ );
+ },
+ function test_highlight_toolbar_button(done) {
+ function check_highlight_size() {
+ let panel = highlight.parentElement;
+ let anchor = panel.anchorNode;
+ let anchorRect = anchor.getBoundingClientRect();
+ info(
+ "addons target: width: " +
+ anchorRect.width +
+ " height: " +
+ anchorRect.height
+ );
+ let dimension = anchorRect.width;
+ let highlightRect = highlight.getBoundingClientRect();
+ info(
+ "highlight: width: " +
+ highlightRect.width +
+ " height: " +
+ highlightRect.height
+ );
+ is(
+ Math.round(highlightRect.width),
+ dimension,
+ "The width of the highlight should be equal to the width of the target"
+ );
+ is(
+ Math.round(highlightRect.height),
+ dimension,
+ "The height of the highlight should be equal to the width of the target"
+ );
+ is(
+ highlight.classList.contains("rounded-highlight"),
+ true,
+ "Highlight should be rounded-rectangle styled"
+ );
+ CustomizableUI.removeWidgetFromArea("home-button");
+ done();
+ }
+ info("Adding home button.");
+ CustomizableUI.addWidgetToArea("home-button", "nav-bar");
+ // Force the button to get layout so we can show the highlight.
+ document.getElementById("home-button").clientHeight;
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ gContentAPI.showHighlight("home");
+ waitForElementToBeVisible(
+ highlight,
+ check_highlight_size,
+ "Highlight should be shown after showHighlight()"
+ );
+ },
+ function test_highlight_addons_auto_open_close(done) {
+ let highlight = document.getElementById("UITourHighlight");
+ gContentAPI.showHighlight("addons");
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+ isnot(
+ highlight.classList.contains("rounded-highlight"),
+ true,
+ "Highlight should not be round-rectangle styled."
+ );
+
+ let hiddenPromise = promisePanelElementHidden(window, PanelUI.panel);
+ // Move the highlight outside which should close the app menu.
+ gContentAPI.showHighlight("appMenu");
+ hiddenPromise.then(() => {
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsClosed() {
+ isnot(
+ PanelUI.panel.state,
+ "open",
+ "Panel should have closed after the highlight moved elsewhere."
+ );
+ done();
+ },
+ "Highlight should move to the appMenu button"
+ );
+ });
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ },
+ function test_highlight_addons_manual_open_close(done) {
+ let highlight = document.getElementById("UITourHighlight");
+ // Manually open the app menu then show a highlight there. The menu should remain open.
+ let shownPromise = promisePanelShown(window);
+ gContentAPI.showMenu("appMenu");
+ shownPromise
+ .then(() => {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+ gContentAPI.showHighlight("addons");
+
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsStillOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should still be open");
+
+ // Move the highlight outside which shouldn't close the app menu since it was manually opened.
+ gContentAPI.showHighlight("appMenu");
+ waitForElementToBeVisible(
+ highlight,
+ function () {
+ isnot(
+ PanelUI.panel.state,
+ "closed",
+ "Panel should remain open since UITour didn't open it in the first place"
+ );
+ gContentAPI.hideMenu("appMenu");
+ done();
+ },
+ "Highlight should move to the appMenu button"
+ );
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ })
+ .catch(console.error);
+ },
+ function test_highlight_effect(done) {
+ function waitForHighlightWithEffect(highlightEl, effect, next, error) {
+ return waitForCondition(
+ () => highlightEl.getAttribute("active") == effect,
+ next,
+ error
+ );
+ }
+ function checkDefaultEffect() {
+ is(
+ highlight.getAttribute("active"),
+ "none",
+ "The default should be no effect"
+ );
+
+ gContentAPI.showHighlight("urlbar", "none");
+ waitForHighlightWithEffect(
+ highlight,
+ "none",
+ checkZoomEffect,
+ "There should be no effect"
+ );
+ }
+ function checkZoomEffect() {
+ gContentAPI.showHighlight("urlbar", "zoom");
+ waitForHighlightWithEffect(
+ highlight,
+ "zoom",
+ () => {
+ let style = window.getComputedStyle(highlight);
+ is(
+ style.animationName,
+ "uitour-zoom",
+ "The animation-name should be uitour-zoom"
+ );
+ checkSameEffectOnDifferentTarget();
+ },
+ "There should be a zoom effect"
+ );
+ }
+ function checkSameEffectOnDifferentTarget() {
+ gContentAPI.showHighlight("appMenu", "wobble");
+ waitForHighlightWithEffect(
+ highlight,
+ "wobble",
+ () => {
+ highlight.addEventListener(
+ "animationstart",
+ function (aEvent) {
+ ok(
+ true,
+ "Animation occurred again even though the effect was the same"
+ );
+ checkRandomEffect();
+ },
+ { once: true }
+ );
+ gContentAPI.showHighlight("backForward", "wobble");
+ },
+ "There should be a wobble effect"
+ );
+ }
+ function checkRandomEffect() {
+ function waitForActiveHighlight(highlightEl, next, error) {
+ return waitForCondition(
+ () => highlightEl.hasAttribute("active"),
+ next,
+ error
+ );
+ }
+
+ gContentAPI.hideHighlight();
+ gContentAPI.showHighlight("urlbar", "random");
+ waitForActiveHighlight(
+ highlight,
+ () => {
+ ok(
+ highlight.hasAttribute("active"),
+ "The highlight should be active"
+ );
+ isnot(
+ highlight.getAttribute("active"),
+ "none",
+ "A random effect other than none should have been chosen"
+ );
+ isnot(
+ highlight.getAttribute("active"),
+ "random",
+ "The random effect shouldn't be 'random'"
+ );
+ isnot(
+ UITour.highlightEffects.indexOf(highlight.getAttribute("active")),
+ -1,
+ "Check that a supported effect was randomly chosen"
+ );
+ done();
+ },
+ "There should be an active highlight with a random effect"
+ );
+ }
+
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ gContentAPI.showHighlight("urlbar");
+ waitForElementToBeVisible(
+ highlight,
+ checkDefaultEffect,
+ "Highlight should be shown after showHighlight()"
+ );
+ },
+ function test_highlight_effect_unsupported(done) {
+ function checkUnsupportedEffect() {
+ is(
+ highlight.getAttribute("active"),
+ "none",
+ "No effect should be used when an unsupported effect is requested"
+ );
+ done();
+ }
+
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ gContentAPI.showHighlight("urlbar", "__UNSUPPORTED__");
+ waitForElementToBeVisible(
+ highlight,
+ checkUnsupportedEffect,
+ "Highlight should be shown after showHighlight()"
+ );
+ },
+ function test_info_1(done) {
+ let popup = document.getElementById("UITourTooltip");
+ let title = document.getElementById("UITourTooltipTitle");
+ let desc = document.getElementById("UITourTooltipDescription");
+ let icon = document.getElementById("UITourTooltipIcon");
+ let buttons = document.getElementById("UITourTooltipButtons");
+
+ popup.addEventListener(
+ "popupshown",
+ function () {
+ is(
+ popup.anchorNode,
+ document.getElementById("urlbar"),
+ "Popup should be anchored to the urlbar"
+ );
+ is(title.textContent, "test title", "Popup should have correct title");
+ is(
+ desc.textContent,
+ "test text",
+ "Popup should have correct description text"
+ );
+ is(icon.src, "", "Popup should have no icon");
+ is(buttons.hasChildNodes(), false, "Popup should have no buttons");
+
+ popup.addEventListener(
+ "popuphidden",
+ function () {
+ popup.addEventListener(
+ "popupshown",
+ function () {
+ done();
+ },
+ { once: true }
+ );
+
+ gContentAPI.showInfo("urlbar", "test title", "test text");
+ },
+ { once: true }
+ );
+ gContentAPI.hideInfo();
+ },
+ { once: true }
+ );
+
+ gContentAPI.showInfo("urlbar", "test title", "test text");
+ },
+ taskify(async function test_info_2() {
+ let popup = document.getElementById("UITourTooltip");
+ let title = document.getElementById("UITourTooltipTitle");
+ let desc = document.getElementById("UITourTooltipDescription");
+ let icon = document.getElementById("UITourTooltipIcon");
+ let buttons = document.getElementById("UITourTooltipButtons");
+
+ await showInfoPromise("urlbar", "urlbar title", "urlbar text");
+
+ is(
+ popup.anchorNode,
+ document.getElementById("urlbar"),
+ "Popup should be anchored to the urlbar"
+ );
+ is(title.textContent, "urlbar title", "Popup should have correct title");
+ is(
+ desc.textContent,
+ "urlbar text",
+ "Popup should have correct description text"
+ );
+ is(icon.src, "", "Popup should have no icon");
+ is(buttons.hasChildNodes(), false, "Popup should have no buttons");
+
+ // Place the search bar in the navigation toolbar temporarily.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.widget.inNavBar", true]],
+ });
+
+ await showInfoPromise("search", "search title", "search text");
+
+ is(
+ popup.anchorNode,
+ document.getElementById("searchbar"),
+ "Popup should be anchored to the searchbar"
+ );
+ is(title.textContent, "search title", "Popup should have correct title");
+ is(
+ desc.textContent,
+ "search text",
+ "Popup should have correct description text"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ }),
+ function test_getConfigurationVersion(done) {
+ function callback(result) {
+ ok(
+ typeof result.version !== "undefined",
+ "Check version isn't undefined."
+ );
+ is(
+ result.version,
+ Services.appinfo.version,
+ "Should have the same version property."
+ );
+ is(
+ result.defaultUpdateChannel,
+ UpdateUtils.getUpdateChannel(false),
+ "Should have the correct update channel."
+ );
+ done();
+ }
+
+ gContentAPI.getConfiguration("appinfo", callback);
+ },
+ function test_getConfigurationDistribution(done) {
+ gContentAPI.getConfiguration("appinfo", result => {
+ ok(
+ typeof result.distribution !== "undefined",
+ "Check distribution isn't undefined."
+ );
+ // distribution id defaults to "default" for most builds, and
+ // "mozilla-MSIX" for MSIX builds.
+ is(
+ result.distribution,
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ? "mozilla-MSIX"
+ : "default",
+ 'Should be "default" without preference set.'
+ );
+
+ let defaults = Services.prefs.getDefaultBranch("distribution.");
+ let testDistributionID = "TestDistribution";
+ defaults.setCharPref("id", testDistributionID);
+ gContentAPI.getConfiguration("appinfo", result2 => {
+ ok(
+ typeof result2.distribution !== "undefined",
+ "Check distribution isn't undefined."
+ );
+ is(
+ result2.distribution,
+ testDistributionID,
+ "Should have the distribution as set in preference."
+ );
+
+ done();
+ });
+ });
+ },
+ function test_getConfigurationProfileAge(done) {
+ gContentAPI.getConfiguration("appinfo", result => {
+ ok(
+ typeof result.profileCreatedWeeksAgo === "number",
+ "profileCreatedWeeksAgo should be number."
+ );
+ ok(
+ result.profileResetWeeksAgo === null,
+ "profileResetWeeksAgo should be null."
+ );
+
+ // Set profile reset date to 15 days ago.
+ ProfileAge().then(profileAccessor => {
+ profileAccessor.recordProfileReset(
+ Date.now() - 15 * 24 * 60 * 60 * 1000
+ );
+ gContentAPI.getConfiguration("appinfo", result2 => {
+ ok(
+ typeof result2.profileResetWeeksAgo === "number",
+ "profileResetWeeksAgo should be number."
+ );
+ is(
+ result2.profileResetWeeksAgo,
+ 2,
+ "profileResetWeeksAgo should be 2."
+ );
+ done();
+ });
+ });
+ });
+ },
+ function test_addToolbarButton(done) {
+ let placement = CustomizableUI.getPlacementOfWidget("panic-button");
+ is(placement, null, "default UI has panic button in the palette");
+
+ gContentAPI.getConfiguration("availableTargets", data => {
+ let available = data.targets.includes("forget");
+ ok(!available, "Forget button should not be available by default");
+
+ gContentAPI.addNavBarWidget("forget", () => {
+ info("addNavBarWidget callback successfully called");
+
+ let updatedPlacement =
+ CustomizableUI.getPlacementOfWidget("panic-button");
+ is(updatedPlacement.area, CustomizableUI.AREA_NAVBAR);
+
+ gContentAPI.getConfiguration("availableTargets", data2 => {
+ let updatedAvailable = data2.targets.includes("forget");
+ ok(updatedAvailable, "Forget button should now be available");
+
+ // Cleanup
+ CustomizableUI.removeWidgetFromArea("panic-button");
+ done();
+ });
+ });
+ });
+ },
+ taskify(async function test_search() {
+ let defaultEngine = await Services.search.getDefault();
+ let visibleEngines = await Services.search.getVisibleEngines();
+ let expectedEngines = visibleEngines
+ .filter(engine => engine.identifier)
+ .map(engine => "searchEngine-" + engine.identifier);
+
+ let data = await new Promise(resolve =>
+ gContentAPI.getConfiguration("search", resolve)
+ );
+ let engines = data.engines;
+ ok(Array.isArray(engines), "data.engines should be an array");
+ is(
+ engines.sort().toString(),
+ expectedEngines.sort().toString(),
+ "Engines should be as expected"
+ );
+
+ is(
+ data.searchEngineIdentifier,
+ defaultEngine.identifier,
+ "the searchEngineIdentifier property should contain the defaultEngine's identifier"
+ );
+
+ let someOtherEngineID = data.engines.filter(
+ t => t != "searchEngine-" + defaultEngine.identifier
+ )[0];
+ someOtherEngineID = someOtherEngineID.replace(/^searchEngine-/, "");
+
+ Services.telemetry.clearEvents();
+ Services.fog.testResetFOG();
+
+ await new Promise(resolve => {
+ let observe = function (subject, topic, verb) {
+ Services.obs.removeObserver(observe, "browser-search-engine-modified");
+ info("browser-search-engine-modified: " + verb);
+ if (verb == "engine-default") {
+ is(
+ Services.search.defaultEngine.identifier,
+ someOtherEngineID,
+ "correct engine was switched to"
+ );
+ resolve();
+ }
+ };
+ Services.obs.addObserver(observe, "browser-search-engine-modified");
+ registerCleanupFunction(async () => {
+ await Services.search.setDefault(
+ defaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ });
+
+ gContentAPI.setDefaultSearchEngine(someOtherEngineID);
+ });
+
+ let engine = (await Services.search.getVisibleEngines()).filter(
+ e => e.identifier == someOtherEngineID
+ )[0];
+
+ let submissionUrl = engine
+ .getSubmission("dummy")
+ .uri.spec.replace("dummy", "");
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "change_default",
+ value: "uitour",
+ extra: {
+ prev_id: defaultEngine.telemetryId,
+ new_id: engine.telemetryId,
+ new_name: engine.name,
+ new_load_path: engine.wrappedJSObject._loadPath,
+ // Telemetry has a limit of 80 characters.
+ new_sub_url: submissionUrl.slice(0, 80),
+ },
+ },
+ ],
+ { category: "search", method: "engine" }
+ );
+
+ let snapshot = await Glean.searchEngineDefault.changed.testGetValue();
+ delete snapshot[0].timestamp;
+ Assert.deepEqual(
+ snapshot[0],
+ {
+ category: "search.engine.default",
+ name: "changed",
+ extra: {
+ change_source: "uitour",
+ previous_engine_id: defaultEngine.telemetryId,
+ new_engine_id: engine.telemetryId,
+ new_display_name: engine.name,
+ new_load_path: engine.wrappedJSObject._loadPath,
+ // Glean has a limit of 100 characters.
+ new_submission_url: submissionUrl.slice(0, 100),
+ },
+ },
+ "Should have received the correct event details"
+ );
+ }),
+ taskify(async function test_treatment_tag() {
+ let ac = new TelemetryArchiveTesting.Checker();
+ await ac.promiseInit();
+ await gContentAPI.setTreatmentTag("foobar", "baz");
+ // Wait until the treatment telemetry is sent before looking in the archive.
+ await BrowserTestUtils.waitForContentEvent(
+ gTestTab.linkedBrowser,
+ "mozUITourNotification",
+ false,
+ event => event.detail.event === "TreatmentTag:TelemetrySent"
+ );
+ await new Promise(resolve => {
+ gContentAPI.getTreatmentTag("foobar", data => {
+ is(data.value, "baz", "set and retrieved treatmentTag");
+ ac.promiseFindPing("uitour-tag", [
+ [["payload", "tagName"], "foobar"],
+ [["payload", "tagValue"], "baz"],
+ ]).then(
+ found => {
+ ok(found, "Telemetry ping submitted for setTreatmentTag");
+ resolve();
+ },
+ err => {
+ ok(false, "Exception finding uitour telemetry ping: " + err);
+ resolve();
+ }
+ );
+ });
+ });
+ }),
+
+ // Make sure this test is last in the file so the appMenu gets left open and done will confirm it got tore down.
+ taskify(async function cleanupMenus() {
+ let shownPromise = promisePanelShown(window);
+ gContentAPI.showMenu("appMenu");
+ await shownPromise;
+ }),
+];
diff --git a/browser/components/uitour/test/browser_UITour2.js b/browser/components/uitour/test/browser_UITour2.js
new file mode 100644
index 0000000000..d911a6142d
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour2.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ function test_info_addons_auto_open_close(done) {
+ let popup = document.getElementById("UITourTooltip");
+ gContentAPI.showInfo("addons", "Addons", "Let's get addons!");
+
+ let shownPromise = promisePanelShown(window);
+ shownPromise.then(() => {
+ UITour.getTarget(window, "addons").then(addonsTarget => {
+ waitForPopupAtAnchor(
+ popup,
+ addonsTarget.node,
+ function checkPanelIsOpen() {
+ isnot(
+ PanelUI.panel.state,
+ "closed",
+ "Panel should have opened before the popup anchored"
+ );
+ ok(
+ PanelUI.panel.hasAttribute("noautohide"),
+ "@noautohide on the menu panel should have been set"
+ );
+
+ // Move the info outside which should close the app menu.
+ gContentAPI.showInfo("appMenu", "Open Me", "You know you want to");
+ UITour.getTarget(window, "appMenu").then(target => {
+ waitForPopupAtAnchor(
+ popup,
+ target.node,
+ function checkPanelIsClosed() {
+ isnot(
+ PanelUI.panel.state,
+ "open",
+ "Panel should have closed after the info moved elsewhere."
+ );
+ ok(
+ !PanelUI.panel.hasAttribute("noautohide"),
+ "@noautohide on the menu panel should have been cleaned up on close"
+ );
+ done();
+ },
+ "Info should move to the appMenu button"
+ );
+ });
+ },
+ "Info panel should be anchored to the addons button"
+ );
+ });
+ });
+ },
+ function test_info_addons_manual_open_close(done) {
+ let popup = document.getElementById("UITourTooltip");
+ // Manually open the app menu then show an info panel there. The menu should remain open.
+ let shownPromise = promisePanelShown(window);
+ gContentAPI.showMenu("appMenu");
+ shownPromise
+ .then(() => {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+ ok(
+ PanelUI.panel.hasAttribute("noautohide"),
+ "@noautohide on the menu panel should have been set"
+ );
+ gContentAPI.showInfo("addons", "Addons", "Let's get addons!");
+
+ UITour.getTarget(window, "addons").then(customizeTarget => {
+ waitForPopupAtAnchor(
+ popup,
+ customizeTarget.node,
+ function () {
+ isnot(
+ PanelUI.panel.state,
+ "closed",
+ "Panel should still be open"
+ );
+ ok(
+ PanelUI.panel.hasAttribute("noautohide"),
+ "@noautohide on the menu panel should still be set"
+ );
+
+ // Move the info outside which shouldn't close the app menu since it was manually opened.
+ gContentAPI.showInfo(
+ "appMenu",
+ "Open Me",
+ "You know you want to"
+ );
+ UITour.getTarget(window, "appMenu").then(target => {
+ waitForPopupAtAnchor(
+ popup,
+ target.node,
+ function () {
+ isnot(
+ PanelUI.panel.state,
+ "closed",
+ "Menu should remain open since UITour didn't open it in the first place"
+ );
+ waitForElementToBeHidden(window.PanelUI.panel, () => {
+ ok(
+ !PanelUI.panel.hasAttribute("noautohide"),
+ "@noautohide on the menu panel should have been cleaned up on close"
+ );
+ done();
+ });
+ gContentAPI.hideMenu("appMenu");
+ },
+ "Info should move to the appMenu button"
+ );
+ });
+ },
+ "Info should be shown after showInfo() for fixed menu panel items"
+ );
+ });
+ })
+ .catch(console.error);
+ },
+ taskify(async function test_bookmarks_menu() {
+ CustomizableUI.addWidgetToArea(
+ "bookmarks-menu-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("bookmarks-menu-button")
+ );
+
+ let bookmarksMenuButton = document.getElementById("bookmarks-menu-button");
+
+ is(bookmarksMenuButton.open, false, "Menu should initially be closed");
+ gContentAPI.showMenu("bookmarks");
+
+ await waitForConditionPromise(() => {
+ return bookmarksMenuButton.open;
+ }, "Menu should be visible after showMenu()");
+
+ gContentAPI.hideMenu("bookmarks");
+ await waitForConditionPromise(() => {
+ return !bookmarksMenuButton.open;
+ }, "Menu should be hidden after hideMenu()");
+ }),
+];
diff --git a/browser/components/uitour/test/browser_UITour3.js b/browser/components/uitour/test/browser_UITour3.js
new file mode 100644
index 0000000000..526994f420
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour3.js
@@ -0,0 +1,317 @@
+"use strict";
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+var gTestTab;
+var gContentAPI;
+
+requestLongerTimeout(2);
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_info_icon() {
+ let popup = document.getElementById("UITourTooltip");
+ let title = document.getElementById("UITourTooltipTitle");
+ let desc = document.getElementById("UITourTooltipDescription");
+ let icon = document.getElementById("UITourTooltipIcon");
+ let buttons = document.getElementById("UITourTooltipButtons");
+
+ // Disable the animation to prevent the mouse clicks from hitting the main
+ // window during the transition instead of the buttons in the popup.
+ popup.setAttribute("animate", "false");
+
+ await showInfoPromise("urlbar", "a title", "some text", "image.png");
+
+ is(title.textContent, "a title", "Popup should have correct title");
+ is(
+ desc.textContent,
+ "some text",
+ "Popup should have correct description text"
+ );
+
+ let imageURL = getRootDirectory(gTestPath) + "image.png";
+ imageURL = imageURL.replace(
+ "chrome://mochitests/content/",
+ "https://example.org/"
+ );
+ is(icon.src, imageURL, "Popup should have correct icon shown");
+
+ is(buttons.hasChildNodes(), false, "Popup should have no buttons");
+});
+
+add_UITour_task(async function test_info_buttons_1() {
+ let popup = document.getElementById("UITourTooltip");
+ let title = document.getElementById("UITourTooltipTitle");
+ let desc = document.getElementById("UITourTooltipDescription");
+ let icon = document.getElementById("UITourTooltipIcon");
+
+ await showInfoPromise(
+ "urlbar",
+ "another title",
+ "moar text",
+ "./image.png",
+ "makeButtons"
+ );
+
+ is(title.textContent, "another title", "Popup should have correct title");
+ is(
+ desc.textContent,
+ "moar text",
+ "Popup should have correct description text"
+ );
+
+ let imageURL = getRootDirectory(gTestPath) + "image.png";
+ imageURL = imageURL.replace(
+ "chrome://mochitests/content/",
+ "https://example.org/"
+ );
+ is(icon.src, imageURL, "Popup should have correct icon shown");
+
+ let buttons = document.getElementById("UITourTooltipButtons");
+ is(buttons.childElementCount, 4, "Popup should have four buttons");
+
+ is(buttons.children[0].nodeName, "label", "Text label should be a <label>");
+ is(
+ buttons.children[0].getAttribute("value"),
+ "Regular text",
+ "Text label should have correct value"
+ );
+ is(
+ buttons.children[0].getAttribute("image"),
+ "",
+ "Text should have no image"
+ );
+ is(buttons.children[0].className, "", "Text should have no class");
+
+ is(buttons.children[1].nodeName, "button", "Link should be a <button>");
+ is(
+ buttons.children[1].getAttribute("label"),
+ "Link",
+ "Link should have correct label"
+ );
+ is(
+ buttons.children[1].getAttribute("image"),
+ "",
+ "Link should have no image"
+ );
+ is(buttons.children[1].className, "button-link", "Check link class");
+
+ is(buttons.children[2].nodeName, "button", "Button 1 should be a <button>");
+ is(
+ buttons.children[2].getAttribute("label"),
+ "Button 1",
+ "First button should have correct label"
+ );
+ is(
+ buttons.children[2].getAttribute("image"),
+ "",
+ "First button should have no image"
+ );
+ is(buttons.children[2].className, "", "Button 1 should have no class");
+
+ is(buttons.children[3].nodeName, "button", "Button 2 should be a <button>");
+ is(
+ buttons.children[3].getAttribute("label"),
+ "Button 2",
+ "Second button should have correct label"
+ );
+ is(
+ buttons.children[3].getAttribute("image"),
+ imageURL,
+ "Second button should have correct image"
+ );
+ is(buttons.children[3].className, "button-primary", "Check button 2 class");
+
+ let promiseHidden = promisePanelElementHidden(window, popup);
+ EventUtils.synthesizeMouseAtCenter(buttons.children[2], {}, window);
+ await promiseHidden;
+
+ ok(true, "Popup should close automatically");
+
+ let returnValue = await waitForCallbackResultPromise();
+ is(returnValue.result, "button1", "Correct callback should have been called");
+});
+
+add_UITour_task(async function test_info_buttons_2() {
+ let popup = document.getElementById("UITourTooltip");
+ let title = document.getElementById("UITourTooltipTitle");
+ let desc = document.getElementById("UITourTooltipDescription");
+ let icon = document.getElementById("UITourTooltipIcon");
+
+ await showInfoPromise(
+ "urlbar",
+ "another title",
+ "moar text",
+ "./image.png",
+ "makeButtons"
+ );
+
+ is(title.textContent, "another title", "Popup should have correct title");
+ is(
+ desc.textContent,
+ "moar text",
+ "Popup should have correct description text"
+ );
+
+ let imageURL = getRootDirectory(gTestPath) + "image.png";
+ imageURL = imageURL.replace(
+ "chrome://mochitests/content/",
+ "https://example.org/"
+ );
+ is(icon.src, imageURL, "Popup should have correct icon shown");
+
+ let buttons = document.getElementById("UITourTooltipButtons");
+ is(buttons.childElementCount, 4, "Popup should have four buttons");
+
+ is(
+ buttons.children[1].getAttribute("label"),
+ "Link",
+ "Link should have correct label"
+ );
+ is(
+ buttons.children[1].getAttribute("image"),
+ "",
+ "Link should have no image"
+ );
+ ok(
+ buttons.children[1].classList.contains("button-link"),
+ "Link should have button-link class"
+ );
+
+ is(
+ buttons.children[2].getAttribute("label"),
+ "Button 1",
+ "First button should have correct label"
+ );
+ is(
+ buttons.children[2].getAttribute("image"),
+ "",
+ "First button should have no image"
+ );
+
+ is(
+ buttons.children[3].getAttribute("label"),
+ "Button 2",
+ "Second button should have correct label"
+ );
+ is(
+ buttons.children[3].getAttribute("image"),
+ imageURL,
+ "Second button should have correct image"
+ );
+
+ let promiseHidden = promisePanelElementHidden(window, popup);
+ EventUtils.synthesizeMouseAtCenter(buttons.children[3], {}, window);
+ await promiseHidden;
+
+ ok(true, "Popup should close automatically");
+
+ let returnValue = await waitForCallbackResultPromise();
+
+ is(returnValue.result, "button2", "Correct callback should have been called");
+});
+
+add_UITour_task(async function test_info_close_button() {
+ let closeButton = document.getElementById("UITourTooltipClose");
+
+ await showInfoPromise(
+ "urlbar",
+ "Close me",
+ "X marks the spot",
+ null,
+ null,
+ "makeInfoOptions"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, window);
+
+ let returnValue = await waitForCallbackResultPromise();
+
+ is(returnValue.result, "closeButton", "Close button callback called");
+});
+
+add_UITour_task(async function test_info_target_callback() {
+ let popup = document.getElementById("UITourTooltip");
+
+ await showInfoPromise(
+ "appMenu",
+ "I want to know when the target is clicked",
+ "*click*",
+ null,
+ null,
+ "makeInfoOptions"
+ );
+
+ await gCUITestUtils.openMainMenu();
+
+ let returnValue = await waitForCallbackResultPromise();
+
+ is(returnValue.result, "target", "target callback called");
+ is(
+ returnValue.data.target,
+ "appMenu",
+ "target callback was from the appMenu"
+ );
+ is(
+ returnValue.data.type,
+ "popupshown",
+ "target callback was from the mousedown"
+ );
+
+ // Cleanup.
+ await hideInfoPromise();
+
+ popup.removeAttribute("animate");
+});
+
+add_UITour_task(async function test_getConfiguration_selectedSearchEngine() {
+ let engine = await Services.search.getDefault();
+ let data = await getConfigurationPromise("selectedSearchEngine");
+ is(
+ data.searchEngineIdentifier,
+ engine.identifier,
+ "Correct engine identifier"
+ );
+});
+
+add_UITour_task(async function test_setSearchTerm() {
+ // Place the search bar in the navigation toolbar temporarily.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.widget.inNavBar", true]],
+ });
+
+ const TERM = "UITour Search Term";
+ await gContentAPI.setSearchTerm(TERM);
+
+ let searchbar = document.getElementById("searchbar");
+ // The UITour gets to the searchbar element through a promise, so the value setting
+ // only happens after a tick.
+ await waitForConditionPromise(
+ () => searchbar.value == TERM,
+ "Correct term set"
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_UITour_task(async function test_clearSearchTerm() {
+ // Place the search bar in the navigation toolbar temporarily.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.widget.inNavBar", true]],
+ });
+
+ await gContentAPI.setSearchTerm("");
+
+ let searchbar = document.getElementById("searchbar");
+ // The UITour gets to the searchbar element through a promise, so the value setting
+ // only happens after a tick.
+ await waitForConditionPromise(
+ () => searchbar.value == "",
+ "Search term cleared"
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/uitour/test/browser_UITour4.js b/browser/components/uitour/test/browser_UITour4.js
new file mode 100644
index 0000000000..718a5331c7
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour4.js
@@ -0,0 +1,235 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(
+ async function test_highligh_between_buttonOnAppMenu_and_buttonOnPageActionPanel() {
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ let appMenu = window.PanelUI.panel;
+ let pageActionPanel = BrowserPageActions.panelNode;
+
+ // Test highlighting the addons button on the app menu
+ let appMenuShownPromise = promisePanelElementShown(window, appMenu);
+ let highlightVisiblePromise = elementVisiblePromise(
+ highlight,
+ "Should show highlight"
+ );
+ gContentAPI.showHighlight("addons");
+ await appMenuShownPromise;
+ await highlightVisiblePromise;
+ is(
+ appMenu.state,
+ "open",
+ "Should open the app menu to highlight the addons button"
+ );
+ is(pageActionPanel.state, "closed", "Shouldn't open the page action panel");
+ is(
+ getShowHighlightTargetName(),
+ "addons",
+ "Should highlight the addons button on the app menu"
+ );
+ }
+);
+
+add_UITour_task(
+ async function test_showInfo_between_buttonOnPageActionPanel_and_buttonOnAppMenu() {
+ let tooltip = document.getElementById("UITourTooltip");
+ is_element_hidden(tooltip, "Tooltip should initially be hidden");
+
+ let appMenu = window.PanelUI.panel;
+ let pageActionPanel = BrowserPageActions.panelNode;
+ let tooltipVisiblePromise = elementVisiblePromise(
+ tooltip,
+ "Should show info tooltip"
+ );
+
+ let appMenuShownPromise = promisePanelElementShown(window, appMenu);
+ await showInfoPromise("addons", "title", "text");
+ await appMenuShownPromise;
+ await tooltipVisiblePromise;
+ is(
+ appMenu.state,
+ "open",
+ "Should open the app menu to show info on the addons button"
+ );
+ is(
+ pageActionPanel.state,
+ "closed",
+ "Should close the page action panel after no more show info for the copyURL button"
+ );
+ is(
+ getShowInfoTargetName(),
+ "addons",
+ "Should show info tooltip on the addons button on the app menu"
+ );
+
+ // Test hiding info tooltip
+ let appMenuHiddenPromise = promisePanelElementHidden(window, appMenu);
+ let tooltipHiddenPromise = elementHiddenPromise(
+ tooltip,
+ "Should hide info"
+ );
+ gContentAPI.hideInfo();
+ await appMenuHiddenPromise;
+ await tooltipHiddenPromise;
+ is(appMenu.state, "closed", "Should close the app menu after hiding info");
+ is(
+ pageActionPanel.state,
+ "closed",
+ "Shouldn't open the page action panel after hiding info"
+ );
+ }
+);
+
+add_UITour_task(
+ async function test_highlight_buttonOnPageActionPanel_and_showInfo_buttonOnAppMenu() {
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+ let tooltip = document.getElementById("UITourTooltip");
+ is_element_hidden(tooltip, "Tooltip should initially be hidden");
+
+ let appMenu = window.PanelUI.panel;
+ let pageActionPanel = BrowserPageActions.panelNode;
+ let pageActionPanelHiddenPromise = Promise.resolve();
+
+ // Test showing info tooltip on the privateWindow button on the app menu
+ let appMenuShownPromise = promisePanelElementShown(window, appMenu);
+ let tooltipVisiblePromise = elementVisiblePromise(
+ tooltip,
+ "Should show info tooltip"
+ );
+ let highlightHiddenPromise = elementHiddenPromise(
+ highlight,
+ "Should hide highlight"
+ );
+ await showInfoPromise("privateWindow", "title", "text");
+ await appMenuShownPromise;
+ await tooltipVisiblePromise;
+ await pageActionPanelHiddenPromise;
+ await highlightHiddenPromise;
+ is(
+ appMenu.state,
+ "open",
+ "Should open the app menu to show info on the privateWindow button"
+ );
+ is(pageActionPanel.state, "closed", "Should close the page action panel");
+ is(
+ getShowInfoTargetName(),
+ "privateWindow",
+ "Should show info tooltip on the privateWindow button on the app menu"
+ );
+
+ // Test hiding info tooltip
+ let appMenuHiddenPromise = promisePanelElementHidden(window, appMenu);
+ let tooltipHiddenPromise = elementHiddenPromise(
+ tooltip,
+ "Should hide info"
+ );
+ gContentAPI.hideInfo();
+ await appMenuHiddenPromise;
+ await tooltipHiddenPromise;
+ is(
+ appMenu.state,
+ "closed",
+ "Should close the app menu after hiding info tooltip"
+ );
+ }
+);
+
+add_UITour_task(
+ async function test_showInfo_buttonOnAppMenu_and_highlight_buttonOnPageActionPanel() {
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+ let tooltip = document.getElementById("UITourTooltip");
+ is_element_hidden(tooltip, "Tooltip should initially be hidden");
+
+ let appMenu = window.PanelUI.panel;
+ let pageActionPanel = BrowserPageActions.panelNode;
+
+ // Test showing info tooltip on the privateWindow button on the app menu
+ let appMenuShownPromise = promisePanelElementShown(window, appMenu);
+ let tooltipVisiblePromise = elementVisiblePromise(
+ tooltip,
+ "Should show info tooltip"
+ );
+ await showInfoPromise("privateWindow", "title", "text");
+ await appMenuShownPromise;
+ await tooltipVisiblePromise;
+ is(
+ appMenu.state,
+ "open",
+ "Should open the app menu to show info on the privateWindow button"
+ );
+ is(pageActionPanel.state, "closed", "Shouldn't open the page action panel");
+ is(
+ getShowInfoTargetName(),
+ "privateWindow",
+ "Should show info tooltip on the privateWindow button on the app menu"
+ );
+ }
+);
+
+add_UITour_task(
+ async function test_show_pageActionPanel_and_showInfo_buttonOnAppMenu() {
+ let tooltip = document.getElementById("UITourTooltip");
+ is_element_hidden(tooltip, "Tooltip should initially be hidden");
+
+ let appMenu = window.PanelUI.panel;
+ let pageActionPanel = BrowserPageActions.panelNode;
+
+ // Test showing info tooltip on the privateWindow button on the app menu
+ let appMenuShownPromise = promisePanelElementShown(window, appMenu);
+ let tooltipVisiblePromise = elementVisiblePromise(
+ tooltip,
+ "Should show info tooltip"
+ );
+ await showInfoPromise("privateWindow", "title", "text");
+ await appMenuShownPromise;
+ await tooltipVisiblePromise;
+ is(
+ appMenu.state,
+ "open",
+ "Should open the app menu to show info on the privateWindow button"
+ );
+ is(
+ pageActionPanel.state,
+ "closed",
+ "Check state of the page action panel if it was opened explictly by api user."
+ );
+ is(
+ getShowInfoTargetName(),
+ "privateWindow",
+ "Should show info tooltip on the privateWindow button on the app menu"
+ );
+
+ is_element_visible(tooltip, "Tooltip should still be visible");
+ is(appMenu.state, "open", "Shouldn't close the app menu");
+ is(
+ pageActionPanel.state,
+ "closed",
+ "Should close the page action panel after hideMenu"
+ );
+ is(
+ getShowInfoTargetName(),
+ "privateWindow",
+ "Should still show info tooltip on the privateWindow button on the app menu"
+ );
+
+ // Test hiding info tooltip
+ let appMenuHiddenPromise = promisePanelElementHidden(window, appMenu);
+ let tooltipHiddenPromise = elementHiddenPromise(
+ tooltip,
+ "Should hide info"
+ );
+ gContentAPI.hideInfo();
+ await appMenuHiddenPromise;
+ await tooltipHiddenPromise;
+ is(appMenu.state, "closed", "Should close the app menu after hideInfo");
+ is(pageActionPanel.state, "closed", "Shouldn't open the page action panel");
+ }
+);
diff --git a/browser/components/uitour/test/browser_UITour5.js b/browser/components/uitour/test/browser_UITour5.js
new file mode 100644
index 0000000000..50316d4225
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour5.js
@@ -0,0 +1,60 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_highlight_help_and_show_help_subview() {
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ // Test highlighting the library button
+ let appMenu = PanelUI.panel;
+ let appMenuShownPromise = promisePanelElementShown(window, appMenu);
+ let highlightVisiblePromise = elementVisiblePromise(
+ highlight,
+ "Should show highlight"
+ );
+ gContentAPI.showHighlight("help");
+ await appMenuShownPromise;
+ await highlightVisiblePromise;
+ is(
+ appMenu.state,
+ "open",
+ "Should open the app menu to highlight the help button"
+ );
+ is(
+ getShowHighlightTargetName(),
+ "help",
+ "Should highlight the help button on the app menu"
+ );
+
+ // Click the help button to show the subview
+ let ViewShownPromise = new Promise(resolve => {
+ appMenu.addEventListener("ViewShown", resolve, { once: true });
+ });
+ let highlightHiddenPromise = elementHiddenPromise(
+ highlight,
+ "Should hide highlight"
+ );
+
+ let helpButtonID = "appMenu-help-button2";
+ let helpBtn = document.getElementById(helpButtonID);
+ helpBtn.dispatchEvent(new Event("command"));
+ await highlightHiddenPromise;
+ await ViewShownPromise;
+ let helpView = document.getElementById("PanelUI-helpView");
+ ok(PanelView.forNode(helpView).active, "Should show the help subview");
+ is(
+ appMenu.state,
+ "open",
+ "Should still open the app menu for the help subview"
+ );
+
+ // Clean up
+ let appMenuHiddenPromise = promisePanelElementHidden(window, appMenu);
+ gContentAPI.hideMenu("appMenu");
+ await appMenuHiddenPromise;
+ is(appMenu.state, "closed", "Should close the app menu");
+});
diff --git a/browser/components/uitour/test/browser_UITour_annotation_size_attributes.js b/browser/components/uitour/test/browser_UITour_annotation_size_attributes.js
new file mode 100644
index 0000000000..21742a5951
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_annotation_size_attributes.js
@@ -0,0 +1,65 @@
+/*
+ * Test that width and height attributes don't get set by widget code on the highlight panel.
+ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var highlight = UITour.getHighlightContainerAndMaybeCreate(document);
+var tooltip = UITour.getTooltipAndMaybeCreate(document);
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_highlight_size_attributes() {
+ await gContentAPI.showHighlight("appMenu");
+ await elementVisiblePromise(
+ highlight,
+ "Highlight should be shown after showHighlight() for the appMenu"
+ );
+ await gContentAPI.showHighlight("urlbar");
+ await elementVisiblePromise(
+ highlight,
+ "Highlight should be moved to the urlbar"
+ );
+ await new Promise(resolve => {
+ SimpleTest.executeSoon(() => {
+ is(
+ highlight.style.height,
+ "",
+ "Highlight panel should have no explicit height set"
+ );
+ is(
+ highlight.style.width,
+ "",
+ "Highlight panel should have no explicit width set"
+ );
+ resolve();
+ });
+ });
+});
+
+add_UITour_task(async function test_info_size_attributes() {
+ await gContentAPI.showInfo("appMenu", "test title", "test text");
+ await elementVisiblePromise(
+ tooltip,
+ "Tooltip should be shown after showInfo() for the appMenu"
+ );
+ await gContentAPI.showInfo("urlbar", "new title", "new text");
+ await elementVisiblePromise(tooltip, "Tooltip should be moved to the urlbar");
+ await new Promise(resolve => {
+ SimpleTest.executeSoon(() => {
+ is(
+ tooltip.style.height,
+ "",
+ "Info panel should have no explicit height set"
+ );
+ is(
+ tooltip.style.width,
+ "",
+ "Info panel should have no explicit width set"
+ );
+ resolve();
+ });
+ });
+});
diff --git a/browser/components/uitour/test/browser_UITour_availableTargets.js b/browser/components/uitour/test/browser_UITour_availableTargets.js
new file mode 100644
index 0000000000..0e9ac45513
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_availableTargets.js
@@ -0,0 +1,129 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+var hasPocket = Services.prefs.getBoolPref("extensions.pocket.enabled");
+var hasQuit = AppConstants.platform != "macosx";
+
+requestLongerTimeout(2);
+
+function getExpectedTargets() {
+ return [
+ "accountStatus",
+ "addons",
+ "appMenu",
+ "backForward",
+ "help",
+ "logins",
+ "pageAction-bookmark",
+ ...(hasPocket ? ["pocket"] : []),
+ "privateWindow",
+ ...(hasQuit ? ["quit"] : []),
+ "readerMode-urlBar",
+ "urlbar",
+ ];
+}
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_availableTargets() {
+ let data = await getConfigurationPromise("availableTargets");
+ let expecteds = getExpectedTargets();
+ ok_targets(data, expecteds);
+ ok(UITour.availableTargetsCache.has(window), "Targets should now be cached");
+});
+
+add_UITour_task(async function test_availableTargets_changeWidgets() {
+ CustomizableUI.addWidgetToArea(
+ "bookmarks-menu-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ ok(
+ !UITour.availableTargetsCache.has(window),
+ "Targets should be evicted from cache after widget change"
+ );
+ let data = await getConfigurationPromise("availableTargets");
+ let expecteds = getExpectedTargets();
+ expecteds = ["bookmarks", ...expecteds];
+ ok_targets(data, expecteds);
+
+ ok(
+ UITour.availableTargetsCache.has(window),
+ "Targets should now be cached again"
+ );
+ CustomizableUI.reset();
+ ok(
+ !UITour.availableTargetsCache.has(window),
+ "Targets should not be cached after reset"
+ );
+});
+
+add_UITour_task(async function test_availableTargets_search() {
+ Services.prefs.setBoolPref("browser.search.widget.inNavBar", true);
+ try {
+ let data = await getConfigurationPromise("availableTargets");
+ let expecteds = getExpectedTargets();
+ expecteds = ["search", "searchIcon", ...expecteds];
+ ok_targets(data, expecteds);
+ } finally {
+ Services.prefs.clearUserPref("browser.search.widget.inNavBar");
+ }
+});
+
+function ok_targets(actualData, expectedTargets) {
+ // Depending on how soon after page load this is called, the selected tab icon
+ // may or may not be showing the loading throbber. We can't be sure whether
+ // it appears in the list of targets, so remove it.
+ let index = actualData.targets.indexOf("selectedTabIcon");
+ if (index != -1) {
+ actualData.targets.splice(index, 1);
+ }
+
+ ok(Array.isArray(actualData.targets), "data.targets should be an array");
+ actualData.targets.sort();
+ expectedTargets.sort();
+ Assert.deepEqual(
+ actualData.targets,
+ expectedTargets,
+ "Targets should be as expected"
+ );
+ if (actualData.targets.toString() != expectedTargets.toString()) {
+ for (let actualItem of actualData.targets) {
+ if (!expectedTargets.includes(actualItem)) {
+ ok(false, `${actualItem} was an unexpected target.`);
+ }
+ }
+ for (let expectedItem of expectedTargets) {
+ if (!actualData.targets.includes(expectedItem)) {
+ ok(false, `${expectedItem} should have been a target.`);
+ }
+ }
+ }
+}
+
+async function assertTargetNode(targetName, expectedNodeId) {
+ let target = await UITour.getTarget(window, targetName);
+ is(target.node.id, expectedNodeId, "UITour should get the right target node");
+}
+
+var pageActionsHelper = {
+ setActionsUrlbarState(inUrlbar) {
+ this._originalStates = [];
+ PageActions._actionsByID.forEach(action => {
+ this._originalStates.push([action, action.pinnedToUrlbar]);
+ action.pinnedToUrlbar = inUrlbar;
+ });
+ },
+
+ restoreActionsUrlbarState() {
+ if (!this._originalStates) {
+ return;
+ }
+ for (let [action, originalState] of this._originalStates) {
+ action.pinnedToUrlbar = originalState;
+ }
+ this._originalStates = null;
+ },
+};
diff --git a/browser/components/uitour/test/browser_UITour_colorway.js b/browser/components/uitour/test/browser_UITour_colorway.js
new file mode 100644
index 0000000000..ede28a38b0
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_colorway.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+add_task(setup_UITourTest);
+
+// Tests assume there's at least 1 builtin theme with colorway id.
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+const COLORWAY_IDS = [...BuiltInThemes.builtInThemeMap.keys()].filter(
+ id =>
+ id.endsWith("-colorway@mozilla.org") && !BuiltInThemes.themeIsExpired(id)
+);
+
+add_UITour_task(async function test_getColorways() {
+ const data = await getConfigurationPromise("colorway");
+
+ ok(
+ Array.isArray(data),
+ "getConfiguration result should be an array of colorways"
+ );
+});
+
+add_UITour_task(async function test_setColorway_unknown() {
+ await gContentAPI.setConfiguration("colorway", "unknown");
+
+ ok(
+ (await AddonManager.getAddonByID("default-theme@mozilla.org")).isActive,
+ "gContentAPI did not activate unknown colorway"
+ );
+});
+
+add_UITour_task(async function test_setColorway() {
+ const id = COLORWAY_IDS.at(0);
+ if (!id) {
+ info("No colorways to test");
+ return;
+ }
+
+ await gContentAPI.setConfiguration("colorway", id);
+
+ ok(
+ (await AddonManager.getAddonByID(id)).isActive,
+ `gContentAPI activated colorway ${id}`
+ );
+});
+
+add_UITour_task(async function test_anotherColorway() {
+ const id = COLORWAY_IDS.at(-1);
+ if (!id) {
+ info("No colorways to test");
+ return;
+ }
+
+ await gContentAPI.setConfiguration("colorway", id);
+
+ ok(
+ (await AddonManager.getAddonByID(id)).isActive,
+ `gContentAPI activated another colorway ${id}`
+ );
+});
+
+add_UITour_task(async function test_resetColorway() {
+ await gContentAPI.setConfiguration("colorway");
+
+ ok(
+ (await AddonManager.getAddonByID("default-theme@mozilla.org")).isActive,
+ "gContentAPI reset colorway to original theme"
+ );
+});
diff --git a/browser/components/uitour/test/browser_UITour_defaultBrowser.js b/browser/components/uitour/test/browser_UITour_defaultBrowser.js
new file mode 100644
index 0000000000..b05e988a67
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_defaultBrowser.js
@@ -0,0 +1,66 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var setDefaultBrowserCalled = false;
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/MockObjects.js",
+ this
+);
+
+function MockShellService() {}
+MockShellService.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIShellService"]),
+ isDefaultBrowser(aStartupCheck, aForAllTypes) {
+ return false;
+ },
+ setDefaultBrowser(aClaimAllTypes, aForAllUsers) {
+ setDefaultBrowserCalled = true;
+ },
+ shouldCheckDefaultBrowser: false,
+ canSetDesktopBackground: false,
+ BACKGROUND_TILE: 1,
+ BACKGROUND_STRETCH: 2,
+ BACKGROUND_CENTER: 3,
+ BACKGROUND_FILL: 4,
+ BACKGROUND_FIT: 5,
+ BACKGROUND_SPAN: 6,
+ setDesktopBackground(aElement, aPosition) {},
+ desktopBackgroundColor: 0,
+};
+
+var mockShellService = new MockObjectRegisterer(
+ "@mozilla.org/browser/shell-service;1",
+ MockShellService
+);
+
+// Temporarily disabled, see note at test_setDefaultBrowser.
+// mockShellService.register();
+
+add_task(setup_UITourTest);
+
+/* This test is disabled (bug 1180714) since the MockObjectRegisterer
+ is not actually replacing the original ShellService.
+add_UITour_task(function* test_setDefaultBrowser() {
+ try {
+ yield gContentAPI.setConfiguration("defaultBrowser");
+ ok(setDefaultBrowserCalled, "setDefaultBrowser called");
+ } finally {
+ mockShellService.unregister();
+ }
+});
+*/
+
+add_UITour_task(async function test_isDefaultBrowser() {
+ let shell = Cc["@mozilla.org/browser/shell-service;1"].getService(
+ Ci.nsIShellService
+ );
+ let isDefault = shell.isDefaultBrowser(false);
+ let data = await getConfigurationPromise("appinfo");
+ is(
+ isDefault,
+ data.defaultBrowser,
+ "gContentAPI result should match shellService.isDefaultBrowser"
+ );
+});
diff --git a/browser/components/uitour/test/browser_UITour_detach_tab.js b/browser/components/uitour/test/browser_UITour_detach_tab.js
new file mode 100644
index 0000000000..8e4256841b
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_detach_tab.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Detaching a tab to a new window shouldn't break the menu panel.
+ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentDoc;
+
+var detachedWindow;
+
+function test() {
+ registerCleanupFunction(function () {
+ gContentDoc = null;
+ });
+ UITourTest();
+}
+
+/**
+ * When tab is changed we're tearing the tour down. So the UITour client has to always be aware of this
+ * fact and therefore listens to pageshow events.
+ * In particular this scenario happens for detaching the tab (ie. moving it to a new window).
+ */
+var tests = [
+ taskify(async function test_move_tab_to_new_window() {
+ const myDocIdentifier =
+ "Hello, I'm a unique expando to identify this document.";
+
+ let highlight = document.getElementById("UITourHighlight");
+
+ let browserStartupDeferred = PromiseUtils.defer();
+ Services.obs.addObserver(function onBrowserDelayedStartup(aWindow) {
+ Services.obs.removeObserver(
+ onBrowserDelayedStartup,
+ "browser-delayed-startup-finished"
+ );
+ browserStartupDeferred.resolve(aWindow);
+ }, "browser-delayed-startup-finished");
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [myDocIdentifier],
+ contentMyDocIdentifier => {
+ let onPageShow = () => {
+ if (!content.document.hidden) {
+ let win = Cu.waiveXrays(content);
+ win.Mozilla.UITour.showHighlight("appMenu");
+ }
+ };
+ content.window.addEventListener("pageshow", onPageShow, {
+ mozSystemGroup: true,
+ });
+ content.document.myExpando = contentMyDocIdentifier;
+ }
+ );
+ gContentAPI.showHighlight("appMenu");
+
+ await elementVisiblePromise(highlight, "old window highlight");
+
+ detachedWindow = gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ await browserStartupDeferred.promise;
+
+ // This highlight should be shown thanks to the pageshow listener.
+ let newWindowHighlight = UITour.getHighlightAndMaybeCreate(
+ detachedWindow.document
+ );
+ await elementVisiblePromise(newWindowHighlight, "new window highlight");
+
+ let selectedTab = detachedWindow.gBrowser.selectedTab;
+ await SpecialPowers.spawn(
+ selectedTab.linkedBrowser,
+ [myDocIdentifier],
+ contentMyDocIdentifier => {
+ is(
+ content.document.myExpando,
+ contentMyDocIdentifier,
+ "Document should be selected in new window"
+ );
+ }
+ );
+ ok(
+ UITour.tourBrowsersByWindow &&
+ UITour.tourBrowsersByWindow.has(detachedWindow),
+ "Window should be known"
+ );
+ ok(
+ UITour.tourBrowsersByWindow
+ .get(detachedWindow)
+ .has(selectedTab.linkedBrowser),
+ "Selected browser should be known"
+ );
+
+ // Need this because gContentAPI in e10s land will try to use gTestTab to
+ // spawn a content task, which doesn't work if the tab is dead, for obvious
+ // reasons.
+ gTestTab = detachedWindow.gBrowser.selectedTab;
+
+ let shownPromise = promisePanelShown(detachedWindow);
+ gContentAPI.showMenu("appMenu");
+ await shownPromise;
+
+ isnot(detachedWindow.PanelUI.panel.state, "closed", "Panel should be open");
+ gContentAPI.hideHighlight();
+ gContentAPI.hideMenu("appMenu");
+ gTestTab = null;
+
+ await BrowserTestUtils.closeWindow(detachedWindow);
+ }),
+];
diff --git a/browser/components/uitour/test/browser_UITour_forceReaderMode.js b/browser/components/uitour/test/browser_UITour_forceReaderMode.js
new file mode 100644
index 0000000000..b69956c447
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_forceReaderMode.js
@@ -0,0 +1,24 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function () {
+ ok(
+ !gBrowser.selectedBrowser.isArticle,
+ "Should not be an article when we start"
+ );
+ ok(
+ document.getElementById("reader-mode-button").hidden,
+ "Button should be hidden."
+ );
+ await gContentAPI.forceShowReaderIcon();
+ await waitForConditionPromise(() => gBrowser.selectedBrowser.isArticle);
+ ok(gBrowser.selectedBrowser.isArticle, "Should suddenly be an article.");
+ ok(
+ !document.getElementById("reader-mode-button").hidden,
+ "Button should now be visible."
+ );
+});
diff --git a/browser/components/uitour/test/browser_UITour_modalDialog.js b/browser/components/uitour/test/browser_UITour_modalDialog.js
new file mode 100644
index 0000000000..397269bcb9
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_modalDialog.js
@@ -0,0 +1,116 @@
+"use strict";
+
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+var gTestTab;
+var gContentAPI;
+var handleDialog;
+
+// Modified from toolkit/components/passwordmgr/test/prompt_common.js
+var didDialog;
+
+var timer; // keep in outer scope so it's not GC'd before firing
+function startCallbackTimer() {
+ didDialog = false;
+
+ // Delay before the callback twiddles the prompt.
+ const dialogDelay = 10;
+
+ // Use a timer to invoke a callback to twiddle the authentication dialog
+ timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(observer, dialogDelay, Ci.nsITimer.TYPE_ONE_SHOT);
+}
+
+var observer = SpecialPowers.wrapCallbackObject({
+ QueryInterface(iid) {
+ const interfaces = [
+ Ci.nsIObserver,
+ Ci.nsISupports,
+ Ci.nsISupportsWeakReference,
+ ];
+
+ if (
+ !interfaces.some(function (v) {
+ return iid.equals(v);
+ })
+ ) {
+ throw SpecialPowers.Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ return this;
+ },
+
+ observe(subject, topic, data) {
+ var doc = getDialogDoc();
+ if (doc) {
+ handleDialog(doc);
+ } else {
+ startCallbackTimer();
+ } // try again in a bit
+ },
+});
+
+function getDialogDoc() {
+ // Find the <browser> which contains notifyWindow, by looking
+ // through all the open windows and all the <browsers> in each.
+
+ // var enumerator = wm.getEnumerator("navigator:browser");
+ for (let { docShell } of Services.wm.getEnumerator(null)) {
+ var containedDocShells = docShell.getAllDocShellsInSubtree(
+ docShell.typeChrome,
+ docShell.ENUMERATE_FORWARDS
+ );
+ for (let childDocShell of containedDocShells) {
+ // Get the corresponding document for this docshell
+ // We don't want it if it's not done loading.
+ if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) {
+ continue;
+ }
+ var childDoc = childDocShell.contentViewer.DOMDocument;
+
+ // ok(true, "Got window: " + childDoc.location.href);
+ if (
+ childDoc.location.href == "chrome://global/content/commonDialog.xhtml"
+ ) {
+ return childDoc;
+ }
+ }
+ }
+
+ return null;
+}
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ taskify(async function test_modal_dialog_while_opening_tooltip() {
+ let panelShown;
+ let popup;
+
+ handleDialog = doc => {
+ popup = document.getElementById("UITourTooltip");
+ gContentAPI.showInfo("appMenu", "test title", "test text");
+ doc.defaultView.setTimeout(function () {
+ is(
+ popup.state,
+ "closed",
+ "Popup shouldn't be shown while dialog is up"
+ );
+ panelShown = promisePanelElementShown(window, popup);
+ let dialog = doc.getElementById("commonDialog");
+ dialog.acceptDialog();
+ }, 1000);
+ };
+ startCallbackTimer();
+ executeSoon(() => alert("test"));
+ await waitForConditionPromise(
+ () => panelShown,
+ "Timed out waiting for panel promise to be assigned",
+ 100
+ );
+ await panelShown;
+
+ await hideInfoPromise();
+ }),
+];
diff --git a/browser/components/uitour/test/browser_UITour_observe.js b/browser/components/uitour/test/browser_UITour_observe.js
new file mode 100644
index 0000000000..d9ecf6fc7d
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_observe.js
@@ -0,0 +1,99 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+function test() {
+ requestLongerTimeout(2);
+ UITourTest();
+}
+
+var tests = [
+ function test_no_params(done) {
+ function listener(event, params) {
+ is(event, "test-event-1", "Correct event name");
+ ok(!params, "No param object");
+ gContentAPI.observe(null);
+ done();
+ }
+
+ gContentAPI.observe(listener, () => {
+ UITour.notify("test-event-1");
+ });
+ },
+ function test_param_string(done) {
+ function listener(event, params) {
+ is(event, "test-event-2", "Correct event name");
+ is(params, "a param", "Correct param string");
+ gContentAPI.observe(null);
+ done();
+ }
+
+ gContentAPI.observe(listener, () => {
+ UITour.notify("test-event-2", "a param");
+ });
+ },
+ function test_param_object(done) {
+ function listener(event, params) {
+ is(event, "test-event-3", "Correct event name");
+ is(
+ JSON.stringify(params),
+ JSON.stringify({ key: "something" }),
+ "Correct param object"
+ );
+ gContentAPI.observe(null);
+ done();
+ }
+
+ gContentAPI.observe(listener, () => {
+ UITour.notify("test-event-3", { key: "something" });
+ });
+ },
+ function test_background_tab(done) {
+ function listener(event, params) {
+ is(event, "test-event-background-1", "Correct event name");
+ ok(!params, "No param object");
+ gContentAPI.observe(null);
+ gBrowser.removeCurrentTab();
+ done();
+ }
+
+ gContentAPI.observe(listener, () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ isnot(
+ gBrowser.selectedTab,
+ gTestTab,
+ "Make sure the selected tab changed"
+ );
+
+ UITour.notify("test-event-background-1");
+ });
+ },
+ // Make sure the tab isn't torn down when switching back to the tour one.
+ function test_background_then_foreground_tab(done) {
+ let blankTab = null;
+ function listener(event, params) {
+ is(event, "test-event-4", "Correct event name");
+ ok(!params, "No param object");
+ gContentAPI.observe(null);
+ gBrowser.removeTab(blankTab);
+ done();
+ }
+
+ gContentAPI.observe(listener, () => {
+ blankTab = gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:blank"
+ );
+ isnot(
+ gBrowser.selectedTab,
+ gTestTab,
+ "Make sure the selected tab changed"
+ );
+ gBrowser.selectedTab = gTestTab;
+ is(gBrowser.selectedTab, gTestTab, "Switch back to the test tab");
+
+ UITour.notify("test-event-4");
+ });
+ },
+];
diff --git a/browser/components/uitour/test/browser_UITour_panel_close_annotation.js b/browser/components/uitour/test/browser_UITour_panel_close_annotation.js
new file mode 100644
index 0000000000..c12e225cbe
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_panel_close_annotation.js
@@ -0,0 +1,227 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that annotations disappear when their target is hidden.
+ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var highlight = UITour.getHighlightContainerAndMaybeCreate(document);
+var tooltip = UITour.getTooltipAndMaybeCreate(document);
+
+function test() {
+ registerCleanupFunction(() => {
+ // Close the find bar in case it's open in the remaining tab
+ let findBar = gBrowser.getCachedFindBar(gBrowser.selectedTab);
+ if (findBar) {
+ findBar.close();
+ }
+ });
+ UITourTest();
+}
+
+var tests = [
+ function test_highlight_move_outside_panel(done) {
+ gContentAPI.showInfo("urlbar", "test title", "test text");
+ gContentAPI.showHighlight("addons");
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+
+ // Move the highlight outside which should close the app menu.
+ gContentAPI.showHighlight("appMenu");
+ waitForPopupAtAnchor(
+ highlight.parentElement,
+ document.getElementById("PanelUI-button"),
+ () => {
+ isnot(
+ PanelUI.panel.state,
+ "open",
+ "Panel should have closed after the highlight moved elsewhere."
+ );
+ ok(
+ tooltip.state == "showing" || tooltip.state == "open",
+ "The info panel should have remained open"
+ );
+ done();
+ },
+ "Highlight should move to the appMenu button and still be visible"
+ );
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ },
+
+ function test_highlight_panel_hideMenu(done) {
+ gContentAPI.showHighlight("addons");
+ gContentAPI.showInfo("search", "test title", "test text");
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+
+ // Close the app menu and make sure the highlight also disappeared.
+ gContentAPI.hideMenu("appMenu");
+ waitForElementToBeHidden(
+ highlight,
+ function checkPanelIsClosed() {
+ isnot(
+ PanelUI.panel.state,
+ "open",
+ "Panel still should have closed"
+ );
+ ok(
+ tooltip.state == "showing" || tooltip.state == "open",
+ "The info panel should have remained open"
+ );
+ done();
+ },
+ "Highlight should have disappeared when panel closed"
+ );
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ },
+
+ function test_highlight_panel_click_find(done) {
+ gContentAPI.showHighlight("help");
+ gContentAPI.showInfo("searchIcon", "test title", "test text");
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+
+ // Click the find button which should close the panel.
+ let findButton = document.getElementById("find-button");
+ EventUtils.synthesizeMouseAtCenter(findButton, {});
+ waitForElementToBeHidden(
+ highlight,
+ function checkPanelIsClosed() {
+ isnot(
+ PanelUI.panel.state,
+ "open",
+ "Panel should have closed when the find bar opened"
+ );
+ ok(
+ tooltip.state == "showing" || tooltip.state == "open",
+ "The info panel should have remained open"
+ );
+ done();
+ },
+ "Highlight should have disappeared when panel closed"
+ );
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ },
+
+ function test_highlight_info_panel_click_find(done) {
+ gContentAPI.showHighlight("help");
+ gContentAPI.showInfo("addons", "Add addons!", "awesome!");
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+
+ // Click the find button which should close the panel.
+ let findButton = document.getElementById("find-button");
+ EventUtils.synthesizeMouseAtCenter(findButton, {});
+ waitForElementToBeHidden(
+ highlight,
+ function checkPanelIsClosed() {
+ isnot(
+ PanelUI.panel.state,
+ "open",
+ "Panel should have closed when the find bar opened"
+ );
+ waitForElementToBeHidden(
+ tooltip,
+ function checkTooltipIsClosed() {
+ isnot(
+ tooltip.state,
+ "open",
+ "The info panel should have closed too"
+ );
+ done();
+ },
+ "Tooltip should hide with the menu"
+ );
+ },
+ "Highlight should have disappeared when panel closed"
+ );
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ },
+
+ function test_highlight_panel_open_subview(done) {
+ gContentAPI.showHighlight("addons");
+ gContentAPI.showInfo("backForward", "test title", "test text");
+ waitForElementToBeVisible(
+ highlight,
+ function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+
+ // Click the help button which should open the subview in the panel menu.
+ let helpButton = document.getElementById("PanelUI-help");
+ EventUtils.synthesizeMouseAtCenter(helpButton, {});
+ waitForElementToBeHidden(
+ highlight,
+ function highlightHidden() {
+ is(
+ PanelUI.panel.state,
+ "open",
+ "Panel should have stayed open when the subview opened"
+ );
+ ok(
+ tooltip.state == "showing" || tooltip.state == "open",
+ "The info panel should have remained open"
+ );
+ PanelUI.hide();
+ done();
+ },
+ "Highlight should have disappeared when the subview opened"
+ );
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ },
+
+ function test_info_panel_open_subview(done) {
+ gContentAPI.showHighlight("urlbar");
+ gContentAPI.showInfo("addons", "Add addons!", "Open a subview");
+ waitForElementToBeVisible(
+ tooltip,
+ function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+
+ // Click the help button which should open the subview in the panel menu.
+ let helpButton = document.getElementById("PanelUI-help");
+ EventUtils.synthesizeMouseAtCenter(helpButton, {});
+ waitForElementToBeHidden(
+ tooltip,
+ function tooltipHidden() {
+ is(
+ PanelUI.panel.state,
+ "open",
+ "Panel should have stayed open when the subview opened"
+ );
+ is(
+ highlight.parentElement.state,
+ "open",
+ "The highlight should have remained open"
+ );
+ PanelUI.hide();
+ done();
+ },
+ "Tooltip should have disappeared when the subview opened"
+ );
+ },
+ "Highlight should be shown after showHighlight() for fixed panel items"
+ );
+ },
+];
diff --git a/browser/components/uitour/test/browser_UITour_pocket.js b/browser/components/uitour/test/browser_UITour_pocket.js
new file mode 100644
index 0000000000..072e1251e1
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_pocket.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_menu_show() {
+ let panel = BrowserPageActions.activatedActionPanelNode;
+ Assert.ok(
+ !panel || panel.state == "closed",
+ "Pocket panel should initially be closed"
+ );
+ gContentAPI.showMenu("pocket");
+
+ // The panel gets created dynamically.
+ panel = null;
+ await waitForConditionPromise(() => {
+ panel = BrowserPageActions.activatedActionPanelNode;
+ return panel && panel.state == "open";
+ }, "Menu should be visible after showMenu()");
+
+ Assert.ok(
+ !panel.hasAttribute("noautohide"),
+ "@noautohide shouldn't be on the pocket panel"
+ );
+
+ panel.hidePopup();
+ await new Promise(resolve => {
+ panel = BrowserPageActions.activatedActionPanelNode;
+ if (!panel || panel.state == "closed") {
+ resolve();
+ }
+ });
+});
diff --git a/browser/components/uitour/test/browser_UITour_resetProfile.js b/browser/components/uitour/test/browser_UITour_resetProfile.js
new file mode 100644
index 0000000000..921b4bcefe
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_resetProfile.js
@@ -0,0 +1,46 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+// Test that a reset profile dialog appears when "resetFirefox" event is triggered
+add_UITour_task(async function test_resetFirefox() {
+ let canReset = await getConfigurationPromise("canReset");
+ ok(
+ !canReset,
+ "Shouldn't be able to reset from mochitest's temporary profile."
+ );
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://global/content/resetProfile.xhtml",
+ {
+ isSubDialog: true,
+ }
+ );
+
+ // make reset possible.
+ let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
+ Ci.nsIToolkitProfileService
+ );
+ let currentProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let profileName = "mochitest-test-profile-temp-" + Date.now();
+ let tempProfile = profileService.createProfile(
+ currentProfileDir,
+ profileName
+ );
+ canReset = await getConfigurationPromise("canReset");
+ ok(
+ canReset,
+ "Should be able to reset from mochitest's temporary profile once it's in the profile manager."
+ );
+ await gContentAPI.resetFirefox();
+ await dialogPromise;
+ tempProfile.remove(false);
+ canReset = await getConfigurationPromise("canReset");
+ ok(
+ !canReset,
+ "Shouldn't be able to reset from mochitest's temporary profile once removed from the profile manager."
+ );
+});
diff --git a/browser/components/uitour/test/browser_UITour_showNewTab.js b/browser/components/uitour/test/browser_UITour_showNewTab.js
new file mode 100644
index 0000000000..386de3920f
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_showNewTab.js
@@ -0,0 +1,25 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+// Test that we can switch to about:newtab
+add_UITour_task(async function test_aboutNewTab() {
+ let newTabLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ "about:newtab"
+ );
+ info("Showing about:newtab");
+ await gContentAPI.showNewTab();
+ info("Waiting for about:newtab to load");
+ await newTabLoaded;
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "about:newtab",
+ "Loaded about:newtab"
+ );
+ ok(gURLBar.focused, "Address bar gets focus");
+});
diff --git a/browser/components/uitour/test/browser_UITour_showProtectionReport.js b/browser/components/uitour/test/browser_UITour_showProtectionReport.js
new file mode 100644
index 0000000000..fd8be561ac
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_showProtectionReport.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.contentblocking.database.enabled", false],
+ ["browser.contentblocking.report.monitor.enabled", false],
+ ["browser.contentblocking.report.lockwise.enabled", false],
+ ["browser.contentblocking.report.proxy.enabled", false],
+ ],
+ });
+});
+
+// Test that we can switch to about:protections
+add_UITour_task(async function test_openProtectionReport() {
+ let aboutProtectionsLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ "about:protections"
+ );
+ info("Showing about:protections");
+ await gContentAPI.showProtectionReport();
+ info("Waiting for about:protections to load");
+ await aboutProtectionsLoaded;
+ // When the graph is built it means the messaging has finished,
+ // we can close the tab.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(() => {
+ let bars = content.document.querySelectorAll(".graph-bar");
+ return bars.length;
+ }, "The graph has been built");
+ });
+
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "about:protections",
+ "Loaded about:protections"
+ );
+});
diff --git a/browser/components/uitour/test/browser_UITour_sync.js b/browser/components/uitour/test/browser_UITour_sync.js
new file mode 100644
index 0000000000..e7393966ce
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_sync.js
@@ -0,0 +1,231 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+const MOCK_FLOW_ID =
+ "5445b28b8b7ba6cf71e345f8fff4bc59b2a514f78f3e2cc99b696449427fd445";
+const MOCK_FLOW_BEGIN_TIME = 1590780440325;
+const MOCK_DEVICE_ID = "7e450f3337d3479b8582ea1c9bb5ba6c";
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
+ Services.prefs.clearUserPref("services.sync.username");
+});
+
+add_task(setup_UITourTest);
+
+add_setup(async function () {
+ Services.prefs.setCharPref(
+ "identity.fxaccounts.remote.root",
+ "https://example.com"
+ );
+});
+
+add_UITour_task(async function test_checkSyncSetup_disabled() {
+ let result = await getConfigurationPromise("sync");
+ is(result.setup, false, "Sync shouldn't be setup by default");
+});
+
+add_UITour_task(async function test_checkSyncSetup_enabled() {
+ Services.prefs.setCharPref(
+ "services.sync.username",
+ "uitour@tests.mozilla.org"
+ );
+ let result = await getConfigurationPromise("sync");
+ is(result.setup, true, "Sync should be setup");
+});
+
+add_UITour_task(async function test_checkSyncCounts() {
+ Services.prefs.setIntPref("services.sync.clients.devices.desktop", 4);
+ Services.prefs.setIntPref("services.sync.clients.devices.mobile", 5);
+ Services.prefs.setIntPref("services.sync.numClients", 9);
+ let result = await getConfigurationPromise("sync");
+ is(result.mobileDevices, 5, "mobileDevices should be set");
+ is(result.desktopDevices, 4, "desktopDevices should be set");
+ is(result.totalDevices, 9, "totalDevices should be set");
+
+ Services.prefs.clearUserPref("services.sync.clients.devices.desktop");
+ result = await getConfigurationPromise("sync");
+ is(result.mobileDevices, 5, "mobileDevices should be set");
+ is(result.desktopDevices, 0, "desktopDevices should be 0");
+ is(result.totalDevices, 9, "totalDevices should be set");
+
+ Services.prefs.clearUserPref("services.sync.clients.devices.mobile");
+ result = await getConfigurationPromise("sync");
+ is(result.mobileDevices, 0, "mobileDevices should be 0");
+ is(result.desktopDevices, 0, "desktopDevices should be 0");
+ is(result.totalDevices, 9, "totalDevices should be set");
+
+ Services.prefs.clearUserPref("services.sync.numClients");
+ result = await getConfigurationPromise("sync");
+ is(result.mobileDevices, 0, "mobileDevices should be 0");
+ is(result.desktopDevices, 0, "desktopDevices should be 0");
+ is(result.totalDevices, 0, "totalDevices should be 0");
+});
+
+// The showFirefoxAccounts API is sync related, so we test that here too...
+add_UITour_task(async function test_firefoxAccountsNoParams() {
+ info("Load https://accounts.firefox.com");
+ await gContentAPI.showFirefoxAccounts();
+ await BrowserTestUtils.browserLoaded(
+ gTestTab.linkedBrowser,
+ false,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=uitour&action=email&service=sync"
+ );
+});
+
+add_UITour_task(async function test_firefoxAccountsValidParams() {
+ info("Load https://accounts.firefox.com");
+ await gContentAPI.showFirefoxAccounts({ utm_foo: "foo", utm_bar: "bar" });
+ await BrowserTestUtils.browserLoaded(
+ gTestTab.linkedBrowser,
+ false,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=uitour&action=email&service=sync&utm_foo=foo&utm_bar=bar"
+ );
+});
+
+add_UITour_task(async function test_firefoxAccountsWithEmail() {
+ info("Load https://accounts.firefox.com");
+ await gContentAPI.showFirefoxAccounts(null, null, "foo@bar.com");
+ await BrowserTestUtils.browserLoaded(
+ gTestTab.linkedBrowser,
+ false,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=uitour&email=foo%40bar.com&service=sync"
+ );
+});
+
+add_UITour_task(async function test_firefoxAccountsWithEmailAndFlowParams() {
+ info("Load https://accounts.firefox.com with flow params");
+ const flowParams = {
+ flow_id: MOCK_FLOW_ID,
+ flow_begin_time: MOCK_FLOW_BEGIN_TIME,
+ device_id: MOCK_DEVICE_ID,
+ };
+ await gContentAPI.showFirefoxAccounts(flowParams, null, "foo@bar.com");
+ await BrowserTestUtils.browserLoaded(
+ gTestTab.linkedBrowser,
+ false,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=uitour&email=foo%40bar.com&service=sync&" +
+ `flow_id=${MOCK_FLOW_ID}&flow_begin_time=${MOCK_FLOW_BEGIN_TIME}&device_id=${MOCK_DEVICE_ID}`
+ );
+});
+
+add_UITour_task(
+ async function test_firefoxAccountsWithEmailAndBadFlowParamValues() {
+ info("Load https://accounts.firefox.com with bad flow params");
+ const BAD_MOCK_FLOW_ID = "1";
+ const BAD_MOCK_FLOW_BEGIN_TIME = 100;
+
+ await gContentAPI.showFirefoxAccounts(
+ {
+ flow_id: BAD_MOCK_FLOW_ID,
+ flow_begin_time: MOCK_FLOW_BEGIN_TIME,
+ device_id: MOCK_DEVICE_ID,
+ },
+ null,
+ "foo@bar.com"
+ );
+ await checkFxANotLoaded();
+
+ await gContentAPI.showFirefoxAccounts(
+ {
+ flow_id: MOCK_FLOW_ID,
+ flow_begin_time: BAD_MOCK_FLOW_BEGIN_TIME,
+ device_id: MOCK_DEVICE_ID,
+ },
+ null,
+ "foo@bar.com"
+ );
+ await checkFxANotLoaded();
+ }
+);
+
+add_UITour_task(
+ async function test_firefoxAccountsWithEmailAndMissingFlowParamValues() {
+ info("Load https://accounts.firefox.com with missing flow params");
+
+ await gContentAPI.showFirefoxAccounts(
+ {
+ flow_id: MOCK_FLOW_ID,
+ flow_begin_time: MOCK_FLOW_BEGIN_TIME,
+ },
+ null,
+ "foo@bar.com"
+ );
+ await BrowserTestUtils.browserLoaded(
+ gTestTab.linkedBrowser,
+ false,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=uitour&email=foo%40bar.com&service=sync&" +
+ `flow_id=${MOCK_FLOW_ID}&flow_begin_time=${MOCK_FLOW_BEGIN_TIME}`
+ );
+ }
+);
+
+add_UITour_task(async function test_firefoxAccountsWithEmailAndEntrypoints() {
+ info("Load https://accounts.firefox.com with entrypoint parameters");
+
+ await gContentAPI.showFirefoxAccounts(
+ {
+ entrypoint_experiment: "exp",
+ entrypoint_variation: "var",
+ },
+ "entry",
+ "foo@bar.com"
+ );
+ await BrowserTestUtils.browserLoaded(
+ gTestTab.linkedBrowser,
+ false,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=entry&email=foo%40bar.com&service=sync&" +
+ `entrypoint_experiment=exp&entrypoint_variation=var`
+ );
+});
+
+add_UITour_task(async function test_firefoxAccountsNonAlphaValue() {
+ // All characters in the value are allowed, but they must be automatically escaped.
+ // (we throw a unicode character in there too - it's not auto-utf8 encoded,
+ // but that's ok, so long as it is escaped correctly.)
+ let value = "foo& /=?:\\\xa9";
+ // encodeURIComponent encodes spaces to %20 but we want "+"
+ let expected = encodeURIComponent(value).replace(/%20/g, "+");
+ info("Load https://accounts.firefox.com");
+ await gContentAPI.showFirefoxAccounts({ utm_foo: value });
+ await BrowserTestUtils.browserLoaded(
+ gTestTab.linkedBrowser,
+ false,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=uitour&action=email&service=sync&utm_foo=" +
+ expected
+ );
+});
+
+// A helper to check the request was ignored due to invalid params.
+async function checkFxANotLoaded() {
+ try {
+ await waitForConditionPromise(() => {
+ return gBrowser.selectedBrowser.currentURI.spec.startsWith(
+ "https://example.com"
+ );
+ }, "Check if FxA opened");
+ ok(false, "No FxA tab should have opened");
+ } catch (ex) {
+ ok(true, "No FxA tab opened");
+ }
+}
+
+add_UITour_task(async function test_firefoxAccountsNonObject() {
+ // non-string should be rejected.
+ await gContentAPI.showFirefoxAccounts(99);
+ await checkFxANotLoaded();
+});
+
+add_UITour_task(async function test_firefoxAccountsNonUtmPrefix() {
+ // Any non "utm_" name should should be rejected.
+ await gContentAPI.showFirefoxAccounts({ utm_foo: "foo", bar: "bar" });
+ await checkFxANotLoaded();
+});
+
+add_UITour_task(async function test_firefoxAccountsNonAlphaName() {
+ // Any "utm_" name which includes non-alpha chars should be rejected.
+ await gContentAPI.showFirefoxAccounts({ utm_foo: "foo", "utm_bar=": "bar" });
+ await checkFxANotLoaded();
+});
diff --git a/browser/components/uitour/test/browser_UITour_toggleReaderMode.js b/browser/components/uitour/test/browser_UITour_toggleReaderMode.js
new file mode 100644
index 0000000000..f3f45cdf27
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_toggleReaderMode.js
@@ -0,0 +1,21 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function () {
+ ok(
+ !gBrowser.selectedBrowser.currentURI.spec.startsWith("about:reader"),
+ "Should not be in reader mode at start of test."
+ );
+ await gContentAPI.toggleReaderMode();
+ await waitForConditionPromise(() =>
+ gBrowser.selectedBrowser.currentURI.spec.startsWith("about:reader")
+ );
+ ok(
+ gBrowser.selectedBrowser.currentURI.spec.startsWith("about:reader"),
+ "Should be in reader mode now."
+ );
+});
diff --git a/browser/components/uitour/test/browser_backgroundTab.js b/browser/components/uitour/test/browser_backgroundTab.js
new file mode 100644
index 0000000000..dbf14bdba5
--- /dev/null
+++ b/browser/components/uitour/test/browser_backgroundTab.js
@@ -0,0 +1,57 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+requestLongerTimeout(2);
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_bg_getConfiguration() {
+ info("getConfiguration is on the allowed list so should work");
+ await loadForegroundTab();
+ let data = await getConfigurationPromise("availableTargets");
+ ok(data, "Got data from getConfiguration");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_UITour_task(async function test_bg_showInfo() {
+ info("showInfo isn't on the allowed action list so should be denied");
+ await loadForegroundTab();
+
+ await showInfoPromise(
+ "appMenu",
+ "Hello from the background",
+ "Surprise!"
+ ).then(
+ () => ok(false, "panel shouldn't have shown from a background tab"),
+ () => ok(true, "panel wasn't shown from a background tab")
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+async function loadForegroundTab() {
+ // Spawn a content task that resolves once we're sure the visibilityState was
+ // changed. This state is what the tests in this file rely on.
+ let promise = SpecialPowers.spawn(
+ gBrowser.selectedTab.linkedBrowser,
+ [],
+ async function () {
+ return new Promise(resolve => {
+ let document = content.document;
+ document.addEventListener("visibilitychange", function onStateChange() {
+ Assert.equal(
+ document.visibilityState,
+ "hidden",
+ "UITour page should be hidden now."
+ );
+ document.removeEventListener("visibilitychange", onStateChange);
+ resolve();
+ });
+ });
+ }
+ );
+ await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ await promise;
+ isnot(gBrowser.selectedTab, gTestTab, "Make sure tour tab isn't selected");
+}
diff --git a/browser/components/uitour/test/browser_closeTab.js b/browser/components/uitour/test/browser_closeTab.js
new file mode 100644
index 0000000000..24984303f6
--- /dev/null
+++ b/browser/components/uitour/test/browser_closeTab.js
@@ -0,0 +1,23 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_closeTab() {
+ // Setting gTestTab to null indicates that the tab has already been closed,
+ // and if this does not happen the test run will fail.
+ let closePromise = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabClose"
+ );
+ // In the e10s-case, having content request a tab close might mean
+ // that the ContentTask used to send this closeTab message won't
+ // get a response (since the message manager may have closed down).
+ // So we ignore the Promise that closeTab returns, and use the TabClose
+ // event to tell us when the tab has gone away.
+ gContentAPI.closeTab();
+ await closePromise;
+ gTestTab = null;
+});
diff --git a/browser/components/uitour/test/browser_fxa.js b/browser/components/uitour/test/browser_fxa.js
new file mode 100644
index 0000000000..4b5a867fc4
--- /dev/null
+++ b/browser/components/uitour/test/browser_fxa.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+var gTestTab;
+var gContentAPI;
+
+function test() {
+ UITourTest();
+}
+
+const oldState = UIState.get();
+registerCleanupFunction(async function () {
+ await signOut();
+ gSync.updateAllUI(oldState);
+});
+
+var tests = [
+ taskify(async function test_highlight_accountStatus_loggedOut() {
+ await showMenuPromise("appMenu");
+ await showHighlightPromise("accountStatus");
+ let highlight = document.getElementById("UITourHighlightContainer");
+ is(
+ highlight.getAttribute("targetName"),
+ "accountStatus",
+ "Correct highlight target"
+ );
+ }),
+
+ taskify(async function test_highlight_accountStatus_loggedIn() {
+ gSync.updateAllUI({
+ status: UIState.STATUS_SIGNED_IN,
+ lastSync: new Date(),
+ email: "foo@example.com",
+ });
+ await showMenuPromise("appMenu");
+ await showHighlightPromise("accountStatus");
+ let highlight = document.getElementById("UITourHighlightContainer");
+ is(
+ highlight.getAttribute("targetName"),
+ "accountStatus",
+ "Correct highlight target"
+ );
+ }),
+];
+
+function signOut() {
+ // we always want a "localOnly" signout here...
+ return fxAccounts.signOut(true);
+}
diff --git a/browser/components/uitour/test/browser_fxa_config.js b/browser/components/uitour/test/browser_fxa_config.js
new file mode 100644
index 0000000000..1da8bbc49f
--- /dev/null
+++ b/browser/components/uitour/test/browser_fxa_config.js
@@ -0,0 +1,379 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_no_user() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(fxAccounts, "getSignedInUser").returns(null);
+ let result = await getConfigurationPromise("fxa");
+ Assert.deepEqual(result, { setup: false });
+ sandbox.restore();
+});
+
+add_UITour_task(async function test_no_sync_no_devices() {
+ const sandbox = sinon.createSandbox();
+ sandbox
+ .stub(fxAccounts, "getSignedInUser")
+ .returns({ email: "foo@example.com" });
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => {
+ return [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ ];
+ });
+ sandbox.stub(fxAccounts, "listAttachedOAuthClients").resolves([]);
+ sandbox.stub(fxAccounts, "hasLocalSession").resolves(true);
+
+ let result = await getConfigurationPromise("fxaConnections");
+ Assert.deepEqual(result, {
+ setup: true,
+ numOtherDevices: 0,
+ numDevicesByType: {},
+ accountServices: {},
+ });
+ sandbox.restore();
+});
+
+add_UITour_task(async function test_no_sync_many_devices() {
+ const sandbox = sinon.createSandbox();
+ sandbox
+ .stub(fxAccounts, "getSignedInUser")
+ .returns({ email: "foo@example.com" });
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => {
+ return [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "mobile",
+ },
+ {
+ id: 3,
+ name: "My phone",
+ type: "phone",
+ },
+ {
+ id: 4,
+ name: "Who knows?",
+ },
+ {
+ id: 5,
+ name: "Another desktop",
+ type: "desktop",
+ },
+ {
+ id: 6,
+ name: "Yet Another desktop",
+ type: "desktop",
+ },
+ ];
+ });
+ sandbox.stub(fxAccounts, "listAttachedOAuthClients").resolves([]);
+ sandbox.stub(fxAccounts, "hasLocalSession").resolves(true);
+
+ let result = await getConfigurationPromise("fxaConnections");
+ Assert.deepEqual(result, {
+ setup: true,
+ accountServices: {},
+ numOtherDevices: 5,
+ numDevicesByType: {
+ desktop: 2,
+ mobile: 1,
+ phone: 1,
+ unknown: 1,
+ },
+ });
+ sandbox.restore();
+});
+
+add_UITour_task(async function test_fxa_connections_no_cached_devices() {
+ const sandbox = sinon.createSandbox();
+ sandbox
+ .stub(fxAccounts, "getSignedInUser")
+ .returns({ email: "foo@example.com" });
+ let devicesStub = sandbox.stub(fxAccounts.device, "recentDeviceList");
+ devicesStub.get(() => {
+ // Sinon doesn't seem to support second `getters` returning a different
+ // value, so replace the getter here.
+ devicesStub.get(() => {
+ return [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "mobile",
+ },
+ ];
+ });
+ // and here we want to say "nothing is yet cached"
+ return null;
+ });
+
+ sandbox.stub(fxAccounts, "listAttachedOAuthClients").resolves([]);
+ sandbox.stub(fxAccounts, "hasLocalSession").resolves(true);
+ let rdlStub = sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves();
+
+ let result = await getConfigurationPromise("fxaConnections");
+ Assert.deepEqual(result, {
+ setup: true,
+ accountServices: {},
+ numOtherDevices: 1,
+ numDevicesByType: {
+ mobile: 1,
+ },
+ });
+ Assert.ok(rdlStub.called);
+ sandbox.restore();
+});
+
+add_UITour_task(async function test_account_connections() {
+ const sandbox = sinon.createSandbox();
+ sandbox
+ .stub(fxAccounts, "getSignedInUser")
+ .returns({ email: "foo@example.com" });
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
+ sandbox.stub(fxAccounts, "listAttachedOAuthClients").resolves([
+ {
+ id: "802d56ef2a9af9fa",
+ lastAccessedDaysAgo: 2,
+ },
+ {
+ id: "1f30e32975ae5112",
+ lastAccessedDaysAgo: 10,
+ },
+ {
+ id: null,
+ name: "Some browser",
+ lastAccessedDaysAgo: 10,
+ },
+ {
+ id: "null-last-accessed",
+ lastAccessedDaysAgo: null,
+ },
+ ]);
+ Assert.deepEqual(await getConfigurationPromise("fxaConnections"), {
+ setup: true,
+ numOtherDevices: 0,
+ numDevicesByType: {},
+ accountServices: {
+ "802d56ef2a9af9fa": {
+ id: "802d56ef2a9af9fa",
+ lastAccessedWeeksAgo: 0,
+ },
+ "1f30e32975ae5112": {
+ id: "1f30e32975ae5112",
+ lastAccessedWeeksAgo: 1,
+ },
+ "null-last-accessed": {
+ id: "null-last-accessed",
+ lastAccessedWeeksAgo: null,
+ },
+ },
+ });
+ sandbox.restore();
+});
+
+add_UITour_task(async function test_sync() {
+ const sandbox = sinon.createSandbox();
+ sandbox
+ .stub(fxAccounts, "getSignedInUser")
+ .returns({ email: "foo@example.com" });
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
+ sandbox.stub(fxAccounts, "listAttachedOAuthClients").resolves([]);
+ sandbox.stub(fxAccounts, "hasLocalSession").resolves(true);
+ Services.prefs.setCharPref("services.sync.username", "tests@mozilla.org");
+ Services.prefs.setIntPref("services.sync.clients.devices.desktop", 4);
+ Services.prefs.setIntPref("services.sync.clients.devices.mobile", 5);
+ Services.prefs.setIntPref("services.sync.numClients", 9);
+
+ Assert.deepEqual(await getConfigurationPromise("fxa"), {
+ setup: true,
+ accountStateOK: true,
+ browserServices: {
+ sync: {
+ setup: true,
+ mobileDevices: 5,
+ desktopDevices: 4,
+ totalDevices: 9,
+ },
+ },
+ });
+ Services.prefs.clearUserPref("services.sync.username");
+ Services.prefs.clearUserPref("services.sync.clients.devices.desktop");
+ Services.prefs.clearUserPref("services.sync.clients.devices.mobile");
+ Services.prefs.clearUserPref("services.sync.numClients");
+ sandbox.restore();
+});
+
+add_UITour_task(async function test_fxa_fails() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(fxAccounts, "getSignedInUser").throws();
+ let result = await getConfigurationPromise("fxa");
+ Assert.deepEqual(result, {});
+ sandbox.restore();
+});
+
+/**
+ * Tests that a UITour page can get notifications on FxA sign-in state
+ * changes.
+ */
+add_UITour_task(async function test_fxa_signedin_state_change() {
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ let fxaConfig = await getConfigurationPromise("fxa");
+ Assert.ok(!fxaConfig.setup, "FxA should not yet be set up.");
+
+ // A helper function that waits for the state change event to fire
+ // in content, and returns a Promise that resolves to the status
+ // parameter on the event detail.
+ let waitForSignedInStateChange = () => {
+ return SpecialPowers.spawn(gTestTab.linkedBrowser, [], async () => {
+ let event = await ContentTaskUtils.waitForEvent(
+ content.document,
+ "mozUITourNotification",
+ false,
+ e => {
+ return e.detail.event === "FxA:SignedInStateChange";
+ },
+ true
+ );
+ return event.detail.params.status;
+ });
+ };
+
+ // We'll first test the STATUS_SIGNED_IN status.
+
+ let stateChangePromise = waitForSignedInStateChange();
+
+ // Per bug 1743857, we wait for a JSWindowActor message round trip to
+ // ensure that the mozUITourNotification event listener has been setup
+ // in the SpecialPowers.spawn task.
+ await new Promise(resolve => {
+ gContentAPI.ping(resolve);
+ });
+
+ let UIStateStub = sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "email@example.com",
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let status = await stateChangePromise;
+ Assert.equal(
+ status,
+ UIState.STATUS_SIGNED_IN,
+ "FxA:SignedInStateChange should have notified that we'd signed in."
+ );
+
+ // We'll next test the STATUS_NOT_CONFIGURED status.
+
+ stateChangePromise = waitForSignedInStateChange();
+
+ // Per bug 1743857, we wait for a JSWindowActor message round trip to
+ // ensure that the mozUITourNotification event listener has been setup
+ // in the SpecialPowers.spawn task.
+ await new Promise(resolve => {
+ gContentAPI.ping(resolve);
+ });
+
+ UIStateStub.restore();
+ UIStateStub = sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_NOT_CONFIGURED,
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ status = await stateChangePromise;
+ Assert.equal(
+ status,
+ UIState.STATUS_NOT_CONFIGURED,
+ "FxA:SignedInStateChange should have notified that we're not configured."
+ );
+
+ // We'll next test the STATUS_LOGIN_FAILED status.
+
+ stateChangePromise = waitForSignedInStateChange();
+
+ // Per bug 1743857, we wait for a JSWindowActor message round trip to
+ // ensure that the mozUITourNotification event listener has been setup
+ // in the SpecialPowers.spawn task.
+ await new Promise(resolve => {
+ gContentAPI.ping(resolve);
+ });
+
+ UIStateStub.restore();
+ UIStateStub = sandbox.stub(UIState, "get").returns({
+ email: "foo@example.com",
+ status: UIState.STATUS_LOGIN_FAILED,
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ status = await stateChangePromise;
+ Assert.equal(
+ status,
+ UIState.STATUS_LOGIN_FAILED,
+ "FxA:SignedInStateChange should have notified that login has failed."
+ );
+
+ // We'll next test the STATUS_NOT_VERIFIED status.
+
+ stateChangePromise = waitForSignedInStateChange();
+
+ // Per bug 1743857, we wait for a JSWindowActor message round trip to
+ // ensure that the mozUITourNotification event listener has been setup
+ // in the SpecialPowers.spawn task.
+ await new Promise(resolve => {
+ gContentAPI.ping(resolve);
+ });
+
+ UIStateStub.restore();
+ UIStateStub = sandbox.stub(UIState, "get").returns({
+ email: "foo@example.com",
+ status: UIState.STATUS_NOT_VERIFIED,
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ status = await stateChangePromise;
+ Assert.equal(
+ status,
+ UIState.STATUS_NOT_VERIFIED,
+ "FxA:SignedInStateChange should have notified that the login hasn't yet been verified."
+ );
+
+ sandbox.restore();
+});
diff --git a/browser/components/uitour/test/browser_openPreferences.js b/browser/components/uitour/test/browser_openPreferences.js
new file mode 100644
index 0000000000..04e46086b2
--- /dev/null
+++ b/browser/components/uitour/test/browser_openPreferences.js
@@ -0,0 +1,73 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(async function test_openPreferences() {
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences"
+ );
+ await gContentAPI.openPreferences();
+ let tab = await promiseTabOpened;
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_UITour_task(async function test_openInvalidPreferences() {
+ await gContentAPI.openPreferences(999);
+
+ try {
+ await waitForConditionPromise(() => {
+ return gBrowser.selectedBrowser.currentURI.spec.startsWith(
+ "about:preferences"
+ );
+ }, "Check if about:preferences opened");
+ ok(false, "No about:preferences tab should have opened");
+ } catch (ex) {
+ ok(true, "No about:preferences tab opened: " + ex);
+ }
+});
+
+add_UITour_task(async function test_openPrivacyPreferences() {
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences#privacy"
+ );
+ await gContentAPI.openPreferences("privacy");
+ let tab = await promiseTabOpened;
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_UITour_task(async function test_openPrivacyReports() {
+ if (
+ !AppConstants.MOZ_TELEMETRY_REPORTING &&
+ !(AppConstants.MOZ_DATA_REPORTING && AppConstants.MOZ_CRASHREPORTER)
+ ) {
+ return;
+ }
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences#privacy-reports"
+ );
+ await gContentAPI.openPreferences("privacy-reports");
+ let tab = await promiseTabOpened;
+ await BrowserTestUtils.waitForEvent(gBrowser.selectedBrowser, "Initialized");
+ let doc = gBrowser.selectedBrowser.contentDocument;
+ is(
+ doc.location.hash,
+ "#privacy",
+ "Should not display the reports subcategory in the location hash."
+ );
+ await TestUtils.waitForCondition(
+ () => doc.querySelector(".spotlight"),
+ "Wait for the reports section is spotlighted."
+ );
+ is(
+ doc.querySelector(".spotlight").getAttribute("data-subcategory"),
+ "reports",
+ "The reports section is spotlighted."
+ );
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/uitour/test/browser_openSearchPanel.js b/browser/components/uitour/test/browser_openSearchPanel.js
new file mode 100644
index 0000000000..a60a550458
--- /dev/null
+++ b/browser/components/uitour/test/browser_openSearchPanel.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ function test_openSearchPanel(done) {
+ // If suggestions are enabled, the panel will attempt to use the network to
+ // connect to the suggestions provider, causing the test suite to fail. We
+ // also change the preference to display the search bar during the test.
+ Services.prefs.setBoolPref("browser.search.widget.inNavBar", true);
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.search.widget.inNavBar");
+ Services.prefs.clearUserPref("browser.search.suggest.enabled");
+ });
+
+ let searchbar = document.getElementById("searchbar");
+ ok(!searchbar.textbox.open, "Popup starts as closed");
+ gContentAPI.openSearchPanel(() => {
+ ok(searchbar.textbox.open, "Popup was opened");
+ searchbar.textbox.closePopup();
+ ok(!searchbar.textbox.open, "Popup was closed");
+ done();
+ });
+ },
+];
diff --git a/browser/components/uitour/test/head.js b/browser/components/uitour/test/head.js
new file mode 100644
index 0000000000..07b941ba1c
--- /dev/null
+++ b/browser/components/uitour/test/head.js
@@ -0,0 +1,539 @@
+"use strict";
+
+// This file expects these globals to be defined by the test case.
+/* global gTestTab:true, gContentAPI:true, tests:false */
+
+ChromeUtils.defineESModuleGetters(this, {
+ UITour: "resource:///modules/UITour.sys.mjs",
+});
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const SINGLE_TRY_TIMEOUT = 100;
+const NUMBER_OF_TRIES = 30;
+
+let gProxyCallbackMap = new Map();
+
+function waitForConditionPromise(
+ condition,
+ timeoutMsg,
+ tryCount = NUMBER_OF_TRIES
+) {
+ return new Promise((resolve, reject) => {
+ let tries = 0;
+ function checkCondition() {
+ if (tries >= tryCount) {
+ reject(timeoutMsg);
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ return reject(e);
+ }
+ if (conditionPassed) {
+ return resolve();
+ }
+ tries++;
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ return undefined;
+ }
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ });
+}
+
+function waitForCondition(condition, nextTestFn, errorMsg) {
+ waitForConditionPromise(condition, errorMsg).then(nextTestFn, reason => {
+ ok(false, reason + (reason.stack ? "\n" + reason.stack : ""));
+ });
+}
+
+/**
+ * Wrapper to partially transition tests to Task. Use `add_UITour_task` instead for new tests.
+ */
+function taskify(fun) {
+ return doneFn => {
+ // Output the inner function name otherwise no name will be output.
+ info("\t" + fun.name);
+ return fun().then(doneFn, reason => {
+ console.error(reason);
+ ok(false, reason);
+ doneFn();
+ });
+ };
+}
+
+function is_hidden(element) {
+ let win = element.ownerGlobal;
+ let style = win.getComputedStyle(element);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+ if (win.XULPopupElement.isInstance(element)) {
+ return ["hiding", "closed"].includes(element.state);
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument) {
+ return is_hidden(element.parentNode);
+ }
+
+ return false;
+}
+
+function is_visible(element) {
+ let win = element.ownerGlobal;
+ let style = win.getComputedStyle(element);
+ if (style.display == "none") {
+ return false;
+ }
+ if (style.visibility != "visible") {
+ return false;
+ }
+ if (win.XULPopupElement.isInstance(element) && element.state != "open") {
+ return false;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument) {
+ return is_visible(element.parentNode);
+ }
+
+ return true;
+}
+
+function is_element_visible(element, msg) {
+ isnot(element, null, "Element should not be null, when checking visibility");
+ ok(is_visible(element), msg);
+}
+
+function waitForElementToBeVisible(element, nextTestFn, msg) {
+ waitForCondition(
+ () => is_visible(element),
+ () => {
+ ok(true, msg);
+ nextTestFn();
+ },
+ "Timeout waiting for visibility: " + msg
+ );
+}
+
+function waitForElementToBeHidden(element, nextTestFn, msg) {
+ waitForCondition(
+ () => is_hidden(element),
+ () => {
+ ok(true, msg);
+ nextTestFn();
+ },
+ "Timeout waiting for invisibility: " + msg
+ );
+}
+
+function elementVisiblePromise(element, msg) {
+ return waitForConditionPromise(
+ () => is_visible(element),
+ "Timeout waiting for visibility: " + msg
+ );
+}
+
+function elementHiddenPromise(element, msg) {
+ return waitForConditionPromise(
+ () => is_hidden(element),
+ "Timeout waiting for invisibility: " + msg
+ );
+}
+
+function waitForPopupAtAnchor(popup, anchorNode, nextTestFn, msg) {
+ waitForCondition(
+ () => is_visible(popup) && popup.anchorNode == anchorNode,
+ () => {
+ ok(true, msg);
+ is_element_visible(popup, "Popup should be visible");
+ nextTestFn();
+ },
+ "Timeout waiting for popup at anchor: " + msg
+ );
+}
+
+function getConfigurationPromise(configName) {
+ return SpecialPowers.spawn(
+ gTestTab.linkedBrowser,
+ [configName],
+ contentConfigName => {
+ return new Promise(resolve => {
+ let contentWin = Cu.waiveXrays(content);
+ contentWin.Mozilla.UITour.getConfiguration(contentConfigName, resolve);
+ });
+ }
+ );
+}
+
+function getShowHighlightTargetName() {
+ let highlight = document.getElementById("UITourHighlight");
+ return highlight.parentElement.getAttribute("targetName");
+}
+
+function getShowInfoTargetName() {
+ let tooltip = document.getElementById("UITourTooltip");
+ return tooltip.getAttribute("targetName");
+}
+
+function hideInfoPromise(...args) {
+ let popup = document.getElementById("UITourTooltip");
+ gContentAPI.hideInfo.apply(gContentAPI, args);
+ return promisePanelElementHidden(window, popup);
+}
+
+/**
+ * `buttons` and `options` require functions from the content scope so we take a
+ * function name to call to generate the buttons/options instead of the
+ * buttons/options themselves. This makes the signature differ from the content one.
+ */
+function showInfoPromise(
+ target,
+ title,
+ text,
+ icon,
+ buttonsFunctionName,
+ optionsFunctionName
+) {
+ let popup = document.getElementById("UITourTooltip");
+ let shownPromise = promisePanelElementShown(window, popup);
+ return SpecialPowers.spawn(gTestTab.linkedBrowser, [[...arguments]], args => {
+ let contentWin = Cu.waiveXrays(content);
+ let [
+ contentTarget,
+ contentTitle,
+ contentText,
+ contentIcon,
+ contentButtonsFunctionName,
+ contentOptionsFunctionName,
+ ] = args;
+ let buttons = contentButtonsFunctionName
+ ? contentWin[contentButtonsFunctionName]()
+ : null;
+ let options = contentOptionsFunctionName
+ ? contentWin[contentOptionsFunctionName]()
+ : null;
+ contentWin.Mozilla.UITour.showInfo(
+ contentTarget,
+ contentTitle,
+ contentText,
+ contentIcon,
+ buttons,
+ options
+ );
+ }).then(() => shownPromise);
+}
+
+function showHighlightPromise(...args) {
+ let popup = document.getElementById("UITourHighlightContainer");
+ gContentAPI.showHighlight.apply(gContentAPI, args);
+ return promisePanelElementShown(window, popup);
+}
+
+function showMenuPromise(name) {
+ return SpecialPowers.spawn(gTestTab.linkedBrowser, [name], contentName => {
+ return new Promise(resolve => {
+ let contentWin = Cu.waiveXrays(content);
+ contentWin.Mozilla.UITour.showMenu(contentName, resolve);
+ });
+ });
+}
+
+function waitForCallbackResultPromise() {
+ return SpecialPowers.spawn(gTestTab.linkedBrowser, [], async function () {
+ let contentWin = Cu.waiveXrays(content);
+ await ContentTaskUtils.waitForCondition(() => {
+ return contentWin.callbackResult;
+ }, "callback should be called");
+ return {
+ data: contentWin.callbackData,
+ result: contentWin.callbackResult,
+ };
+ });
+}
+
+function promisePanelShown(win) {
+ let panelEl = win.PanelUI.panel;
+ return promisePanelElementShown(win, panelEl);
+}
+
+function promisePanelElementEvent(win, aPanel, aEvent) {
+ return new Promise((resolve, reject) => {
+ let timeoutId = win.setTimeout(() => {
+ aPanel.removeEventListener(aEvent, onPanelEvent);
+ reject(aEvent + " event did not happen within 5 seconds.");
+ }, 5000);
+
+ function onPanelEvent(e) {
+ aPanel.removeEventListener(aEvent, onPanelEvent);
+ win.clearTimeout(timeoutId);
+ // Wait one tick to let UITour.sys.mjs process the event as well.
+ executeSoon(resolve);
+ }
+
+ aPanel.addEventListener(aEvent, onPanelEvent);
+ });
+}
+
+function promisePanelElementShown(win, aPanel) {
+ return promisePanelElementEvent(win, aPanel, "popupshown");
+}
+
+function promisePanelElementHidden(win, aPanel) {
+ return promisePanelElementEvent(win, aPanel, "popuphidden");
+}
+
+function is_element_hidden(element, msg) {
+ isnot(element, null, "Element should not be null, when checking visibility");
+ ok(is_hidden(element), msg);
+}
+
+function isTourBrowser(aBrowser) {
+ let chromeWindow = aBrowser.ownerGlobal;
+ return (
+ UITour.tourBrowsersByWindow.has(chromeWindow) &&
+ UITour.tourBrowsersByWindow.get(chromeWindow).has(aBrowser)
+ );
+}
+
+async function loadUITourTestPage(callback, host = "https://example.org/") {
+ if (gTestTab) {
+ gProxyCallbackMap.clear();
+ gBrowser.removeTab(gTestTab);
+ }
+
+ if (!window.gProxyCallbackMap) {
+ window.gProxyCallbackMap = gProxyCallbackMap;
+ }
+
+ let url = getRootDirectory(gTestPath) + "uitour.html";
+ url = url.replace("chrome://mochitests/content/", host);
+
+ gTestTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ // When e10s is enabled, make gContentAPI a proxy which has every property
+ // return a function which calls the method of the same name on
+ // contentWin.Mozilla.UITour in a ContentTask.
+ let UITourHandler = {
+ get(target, prop, receiver) {
+ return (...args) => {
+ let browser = gTestTab.linkedBrowser;
+ // We need to proxy any callback functions using messages:
+ let fnIndices = [];
+ args = args.map((arg, index) => {
+ // Replace function arguments with "", and add them to the list of
+ // forwarded functions. We'll construct a function on the content-side
+ // that forwards all its arguments to a message, and we'll listen for
+ // those messages on our side and call the corresponding function with
+ // the arguments we got from the content side.
+ if (typeof arg == "function") {
+ gProxyCallbackMap.set(index, arg);
+ fnIndices.push(index);
+ return "";
+ }
+ return arg;
+ });
+ let taskArgs = {
+ methodName: prop,
+ args,
+ fnIndices,
+ };
+ return SpecialPowers.spawn(
+ browser,
+ [taskArgs],
+ async function (contentArgs) {
+ let contentWin = Cu.waiveXrays(content);
+ let callbacksCalled = 0;
+ let resolveCallbackPromise;
+ let allCallbacksCalledPromise = new Promise(
+ resolve => (resolveCallbackPromise = resolve)
+ );
+ let argumentsWithFunctions = Cu.cloneInto(
+ contentArgs.args.map((arg, index) => {
+ if (arg === "" && contentArgs.fnIndices.includes(index)) {
+ return function () {
+ callbacksCalled++;
+ SpecialPowers.spawnChrome(
+ [index, Array.from(arguments)],
+ (indexParent, argumentsParent) => {
+ // Please note that this handler only allows the callback to be used once.
+ // That means that a single gContentAPI.observer() call can't be used
+ // to observe multiple events.
+ let window = this.browsingContext.topChromeWindow;
+ let cb = window.gProxyCallbackMap.get(indexParent);
+ window.gProxyCallbackMap.delete(indexParent);
+ cb.apply(null, argumentsParent);
+ }
+ );
+ if (callbacksCalled >= contentArgs.fnIndices.length) {
+ resolveCallbackPromise();
+ }
+ };
+ }
+ return arg;
+ }),
+ content,
+ { cloneFunctions: true }
+ );
+ let rv = contentWin.Mozilla.UITour[contentArgs.methodName].apply(
+ contentWin.Mozilla.UITour,
+ argumentsWithFunctions
+ );
+ if (contentArgs.fnIndices.length) {
+ await allCallbacksCalledPromise;
+ }
+ return rv;
+ }
+ );
+ };
+ },
+ };
+ gContentAPI = new Proxy({}, UITourHandler);
+
+ await SimpleTest.promiseFocus(gTestTab.linkedBrowser);
+ callback();
+}
+
+// Wrapper for UITourTest to be used by add_task tests.
+function setup_UITourTest() {
+ return UITourTest(true);
+}
+
+// Use `add_task(setup_UITourTest);` instead as we will fold this into `setup_UITourTest` once all tests are using `add_UITour_task`.
+function UITourTest(usingAddTask = false) {
+ Services.prefs.setBoolPref("browser.uitour.enabled", true);
+ let testHttpsOrigin = "https://example.org";
+ let testHttpOrigin = "http://example.org";
+ PermissionTestUtils.add(
+ testHttpsOrigin,
+ "uitour",
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ testHttpOrigin,
+ "uitour",
+ Services.perms.ALLOW_ACTION
+ );
+
+ UITour.getHighlightContainerAndMaybeCreate(window.document);
+ UITour.getTooltipAndMaybeCreate(window.document);
+
+ // If a test file is using add_task, we don't need to have a test function or
+ // call `waitForExplicitFinish`.
+ if (!usingAddTask) {
+ waitForExplicitFinish();
+ }
+
+ registerCleanupFunction(function () {
+ delete window.gContentAPI;
+ if (gTestTab) {
+ gBrowser.removeTab(gTestTab);
+ }
+ delete window.gTestTab;
+ delete window.gProxyCallbackMap;
+ Services.prefs.clearUserPref("browser.uitour.enabled");
+ PermissionTestUtils.remove(testHttpsOrigin, "uitour");
+ PermissionTestUtils.remove(testHttpOrigin, "uitour");
+ });
+
+ // When using tasks, the harness will call the next added task for us.
+ if (!usingAddTask) {
+ nextTest();
+ }
+}
+
+function done(usingAddTask = false) {
+ info("== Done test, doing shared checks before teardown ==");
+ return new Promise(resolve => {
+ executeSoon(() => {
+ if (gTestTab) {
+ gBrowser.removeTab(gTestTab);
+ }
+ gTestTab = null;
+ gProxyCallbackMap.clear();
+
+ let highlight = document.getElementById("UITourHighlightContainer");
+ is_element_hidden(
+ highlight,
+ "Highlight should be closed/hidden after UITour tab is closed"
+ );
+
+ let tooltip = document.getElementById("UITourTooltip");
+ is_element_hidden(
+ tooltip,
+ "Tooltip should be closed/hidden after UITour tab is closed"
+ );
+
+ ok(
+ !PanelUI.panel.hasAttribute("noautohide"),
+ "@noautohide on the menu panel should have been cleaned up"
+ );
+ ok(
+ !PanelUI.panel.hasAttribute("panelopen"),
+ "The panel shouldn't have @panelopen"
+ );
+ isnot(PanelUI.panel.state, "open", "The panel shouldn't be open");
+ is(
+ document.getElementById("PanelUI-menu-button").hasAttribute("open"),
+ false,
+ "Menu button should know that the menu is closed"
+ );
+
+ info("Done shared checks");
+ if (usingAddTask) {
+ executeSoon(resolve);
+ } else {
+ executeSoon(nextTest);
+ }
+ });
+ });
+}
+
+function nextTest() {
+ if (!tests.length) {
+ info("finished tests in this file");
+ finish();
+ return;
+ }
+ let test = tests.shift();
+ info("Starting " + test.name);
+ waitForFocus(function () {
+ loadUITourTestPage(function () {
+ test(done);
+ });
+ });
+}
+
+/**
+ * All new tests that need the help of `loadUITourTestPage` should use this
+ * wrapper around their test's generator function to reduce boilerplate.
+ */
+function add_UITour_task(func) {
+ let genFun = async function () {
+ await new Promise(resolve => {
+ waitForFocus(function () {
+ loadUITourTestPage(function () {
+ let funcPromise = (func() || Promise.resolve()).then(
+ () => done(true),
+ reason => {
+ ok(false, reason);
+ return done(true);
+ }
+ );
+ resolve(funcPromise);
+ });
+ });
+ });
+ };
+ Object.defineProperty(genFun, "name", {
+ configurable: true,
+ value: func.name,
+ });
+ add_task(genFun);
+}
diff --git a/browser/components/uitour/test/image.png b/browser/components/uitour/test/image.png
new file mode 100644
index 0000000000..597c7fd2cb
--- /dev/null
+++ b/browser/components/uitour/test/image.png
Binary files differ
diff --git a/browser/components/uitour/test/uitour.html b/browser/components/uitour/test/uitour.html
new file mode 100644
index 0000000000..09b8f4bd7f
--- /dev/null
+++ b/browser/components/uitour/test/uitour.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>UITour test</title>
+ <script type="application/javascript" src="UITour-lib.js">
+ </script>
+ <script type="application/javascript">
+ var callbackResult, callbackData;
+ function makeCallback(name) {
+ return (function(data) {
+ callbackResult = name;
+ callbackData = data;
+ });
+ }
+
+ // Defined in content to avoid weird issues when crossing between chrome/content.
+ function makeButtons() {
+ return [
+ {label: "Regular text", style: "text"},
+ {label: "Link", callback: makeCallback("link"), style: "link"},
+ {label: "Button 1", callback: makeCallback("button1")},
+ {label: "Button 2", callback: makeCallback("button2"), icon: "image.png",
+ style: "primary"},
+ ];
+ }
+
+ function makeInfoOptions() {
+ return {
+ closeButtonCallback: makeCallback("closeButton"),
+ targetCallback: makeCallback("target"),
+ };
+ }
+ </script>
+ </head>
+ <body>
+ <h1>UITour tests</h1>
+ <p>Because Firefox is...</p>
+ <p>Never gonna let you down</p>
+ <p>Never gonna give you up</p>
+ </body>
+</html>