diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/uitour | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/uitour')
38 files changed, 7212 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..0df3059425 --- /dev/null +++ b/browser/components/uitour/UITour-lib.js @@ -0,0 +1,841 @@ +/* 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..fef68a5a95 --- /dev/null +++ b/browser/components/uitour/UITour.sys.mjs @@ -0,0 +1,2043 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.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", +}); + +ChromeUtils.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. +ChromeUtils.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. +ChromeUtils.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; + ChromeUtils.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} aOptions 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 {ChromeWindow} 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 {DOMElement} aTarget + * The element to highlight. + * @param {string} [aEffect] + * The effect to use from UITour.highlightEffects or "none". + * @param {object} [aOptions] + * 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) { + await shell.setDefaultBrowser(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.platform == "win" || + 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..de779f16de --- /dev/null +++ b/browser/components/uitour/UITourChild.sys.mjs @@ -0,0 +1,112 @@ +/* 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 permission = Services.perms.testPermissionFromPrincipal( + this.document.nodePrincipal, + UITOUR_PERMISSION + ); + if (permission == Services.perms.ALLOW_ACTION) { + return true; + } + + return 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..7bed2830cb --- /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.toml", +] + +SPHINX_TREES["docs"] = "docs" + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Tours") diff --git a/browser/components/uitour/test/browser.toml b/browser/components/uitour/test/browser.toml new file mode 100644 index 0000000000..2fb9cfa025 --- /dev/null +++ b/browser/components/uitour/test/browser.toml @@ -0,0 +1,79 @@ +[DEFAULT] +support-files = [ + "head.js", + "image.png", + "uitour.html", + "../UITour-lib.js", +] + + +["browser_UITour.js"] +skip-if = [ + "os == 'linux'", # Intermittent failures, bug 951965 + "verify", +] + +["browser_UITour2.js"] + +["browser_UITour3.js"] +fail-if = ["a11y_checks"] # Bug 1854526 clicked UITourTooltipClose may not be focusable + +["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_private_browsing.js"] + +["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 +] + +["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..d9e517af1f --- /dev/null +++ b/browser/components/uitour/test/browser_UITour.js @@ -0,0 +1,757 @@ +/* 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) { + Assert.notStrictEqual( + 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 => { + Assert.notStrictEqual( + 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 => { + Assert.notStrictEqual( + 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 => { + Assert.strictEqual( + typeof result.profileCreatedWeeksAgo, + "number", + "profileCreatedWeeksAgo should be number." + ); + Assert.strictEqual( + 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 => { + Assert.strictEqual( + 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..721ab2f8c0 --- /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(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..f2e5809665 --- /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 = Promise.withResolvers(); + 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..a711ee2f2e --- /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.docViewer.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_private_browsing.js b/browser/components/uitour/test/browser_UITour_private_browsing.js new file mode 100644 index 0000000000..0c5a52f667 --- /dev/null +++ b/browser/components/uitour/test/browser_UITour_private_browsing.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that UITour will work in private browsing windows with + * tabs pointed at sites that have the uitour permission set to + * allowed. + */ +add_task(async function test_privatebrowsing_window() { + // These two constants point to origins that have a default + // uitour allow entry in browser/app/permissions. The expectation is + // that these defaults are special, in that they'll apply for both + // private and non-private contexts. + const ABOUT_ORIGIN_WITH_UITOUR_DEFAULT = "about:newtab"; + const HTTPS_ORIGIN_WITH_UITOUR_DEFAULT = "https://www.mozilla.org"; + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let browser = win.gBrowser.selectedBrowser; + + for (let uri of [ + ABOUT_ORIGIN_WITH_UITOUR_DEFAULT, + HTTPS_ORIGIN_WITH_UITOUR_DEFAULT, + ]) { + BrowserTestUtils.startLoadingURIString(browser, uri); + await BrowserTestUtils.browserLoaded(browser); + + await SpecialPowers.spawn(browser, [], async () => { + let actor = content.windowGlobalChild.getActor("UITour"); + Assert.ok( + actor.ensureTrustedOrigin(), + "Page should be considered trusted for UITour." + ); + }); + } + await BrowserTestUtils.closeWindow(win); +}); 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..c1d98a5599 --- /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" +); + +ChromeUtils.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 Binary files differnew file mode 100644 index 0000000000..597c7fd2cb --- /dev/null +++ b/browser/components/uitour/test/image.png 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> |