summaryrefslogtreecommitdiffstats
path: root/browser/components/uitour
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /browser/components/uitour
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'browser/components/uitour')
-rw-r--r--browser/components/uitour/UITour-lib.js331
-rw-r--r--browser/components/uitour/UITour.jsm2111
-rw-r--r--browser/components/uitour/content-UITour.js103
-rw-r--r--browser/components/uitour/jar.mn6
-rw-r--r--browser/components/uitour/moz.build16
-rw-r--r--browser/components/uitour/test/.eslintrc.js7
-rw-r--r--browser/components/uitour/test/browser.ini49
-rw-r--r--browser/components/uitour/test/browser_UITour.js408
-rw-r--r--browser/components/uitour/test/browser_UITour2.js83
-rw-r--r--browser/components/uitour/test/browser_UITour3.js181
-rw-r--r--browser/components/uitour/test/browser_UITour_annotation_size_attributes.js42
-rw-r--r--browser/components/uitour/test/browser_UITour_availableTargets.js114
-rw-r--r--browser/components/uitour/test/browser_UITour_defaultBrowser.js61
-rw-r--r--browser/components/uitour/test/browser_UITour_detach_tab.js94
-rw-r--r--browser/components/uitour/test/browser_UITour_forceReaderMode.js17
-rw-r--r--browser/components/uitour/test/browser_UITour_heartbeat.js755
-rw-r--r--browser/components/uitour/test/browser_UITour_modalDialog.js104
-rw-r--r--browser/components/uitour/test/browser_UITour_observe.js85
-rw-r--r--browser/components/uitour/test/browser_UITour_panel_close_annotation.js153
-rw-r--r--browser/components/uitour/test/browser_UITour_pocket.js82
-rw-r--r--browser/components/uitour/test/browser_UITour_registerPageID.js108
-rw-r--r--browser/components/uitour/test/browser_UITour_resetProfile.js48
-rw-r--r--browser/components/uitour/test/browser_UITour_showNewTab.js17
-rw-r--r--browser/components/uitour/test/browser_UITour_sync.js105
-rw-r--r--browser/components/uitour/test/browser_UITour_toggleReaderMode.js16
-rw-r--r--browser/components/uitour/test/browser_backgroundTab.js46
-rw-r--r--browser/components/uitour/test/browser_closeTab.js18
-rw-r--r--browser/components/uitour/test/browser_fxa.js68
-rw-r--r--browser/components/uitour/test/browser_no_tabs.js102
-rw-r--r--browser/components/uitour/test/browser_openPreferences.js36
-rw-r--r--browser/components/uitour/test/browser_openSearchPanel.js33
-rw-r--r--browser/components/uitour/test/browser_showMenu_controlCenter.js44
-rw-r--r--browser/components/uitour/test/browser_trackingProtection.js90
-rw-r--r--browser/components/uitour/test/browser_trackingProtection_tour.js77
-rw-r--r--browser/components/uitour/test/head.js449
-rw-r--r--browser/components/uitour/test/image.pngbin0 -> 56060 bytes
-rw-r--r--browser/components/uitour/test/uitour.html42
37 files changed, 6101 insertions, 0 deletions
diff --git a/browser/components/uitour/UITour-lib.js b/browser/components/uitour/UITour-lib.js
new file mode 100644
index 000000000..7fe820185
--- /dev/null
+++ b/browser/components/uitour/UITour-lib.js
@@ -0,0 +1,331 @@
+/* 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/. */
+
+// create namespace
+if (typeof Mozilla == 'undefined') {
+ var Mozilla = {};
+}
+
+(function($) {
+ 'use strict';
+
+ // create namespace
+ if (typeof Mozilla.UITour == 'undefined') {
+ Mozilla.UITour = {};
+ }
+
+ var themeIntervalId = null;
+ function _stopCyclingThemes() {
+ if (themeIntervalId) {
+ clearInterval(themeIntervalId);
+ themeIntervalId = null;
+ }
+ }
+
+ function _sendEvent(action, data) {
+ var event = new CustomEvent('mozUITour', {
+ bubbles: true,
+ detail: {
+ action: 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';
+
+ Mozilla.UITour.ping = function(callback) {
+ var data = {};
+ if (callback) {
+ data.callbackID = _waitForCallback(callback);
+ }
+ _sendEvent('ping', data);
+ };
+
+ Mozilla.UITour.observe = function(listener, callback) {
+ notificationListener = listener;
+
+ if (listener) {
+ document.addEventListener('mozUITourNotification',
+ _notificationListener);
+ Mozilla.UITour.ping(callback);
+ } else {
+ document.removeEventListener('mozUITourNotification',
+ _notificationListener);
+ }
+ };
+
+ Mozilla.UITour.registerPageID = function(pageID) {
+ _sendEvent('registerPageID', {
+ pageID: pageID
+ });
+ };
+
+ Mozilla.UITour.showHeartbeat = function(message, thankyouMessage, flowId, engagementURL,
+ learnMoreLabel, learnMoreURL, options) {
+ var args = {
+ message: message,
+ thankyouMessage: thankyouMessage,
+ flowId: flowId,
+ engagementURL: engagementURL,
+ learnMoreLabel: learnMoreLabel,
+ learnMoreURL: learnMoreURL,
+ };
+
+ if (options) {
+ for (var option in options) {
+ if (!options.hasOwnProperty(option)) {
+ continue;
+ }
+ args[option] = options[option];
+ }
+ }
+
+ _sendEvent('showHeartbeat', args);
+ };
+
+ Mozilla.UITour.showHighlight = function(target, effect) {
+ _sendEvent('showHighlight', {
+ target: target,
+ effect: effect
+ });
+ };
+
+ Mozilla.UITour.hideHighlight = function() {
+ _sendEvent('hideHighlight');
+ };
+
+ 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: target,
+ title: title,
+ text: text,
+ icon: icon,
+ buttons: buttonData,
+ closeButtonCallbackID: closeButtonCallbackID,
+ targetCallbackID: targetCallbackID
+ });
+ };
+
+ Mozilla.UITour.hideInfo = function() {
+ _sendEvent('hideInfo');
+ };
+
+ Mozilla.UITour.previewTheme = function(theme) {
+ _stopCyclingThemes();
+
+ _sendEvent('previewTheme', {
+ theme: JSON.stringify(theme)
+ });
+ };
+
+ Mozilla.UITour.resetTheme = function() {
+ _stopCyclingThemes();
+
+ _sendEvent('resetTheme');
+ };
+
+ Mozilla.UITour.cycleThemes = function(themes, delay, callback) {
+ _stopCyclingThemes();
+
+ if (!delay) {
+ delay = Mozilla.UITour.DEFAULT_THEME_CYCLE_DELAY;
+ }
+
+ function nextTheme() {
+ var theme = themes.shift();
+ themes.push(theme);
+
+ _sendEvent('previewTheme', {
+ theme: JSON.stringify(theme),
+ state: true
+ });
+
+ callback(theme);
+ }
+
+ themeIntervalId = setInterval(nextTheme, delay);
+ nextTheme();
+ };
+
+ Mozilla.UITour.showMenu = function(name, callback) {
+ var showCallbackID;
+ if (callback)
+ showCallbackID = _waitForCallback(callback);
+
+ _sendEvent('showMenu', {
+ name: name,
+ showCallbackID: showCallbackID,
+ });
+ };
+
+ Mozilla.UITour.hideMenu = function(name) {
+ _sendEvent('hideMenu', {
+ name: name
+ });
+ };
+
+ Mozilla.UITour.showNewTab = function() {
+ _sendEvent('showNewTab');
+ };
+
+ Mozilla.UITour.getConfiguration = function(configName, callback) {
+ _sendEvent('getConfiguration', {
+ callbackID: _waitForCallback(callback),
+ configuration: configName,
+ });
+ };
+
+ Mozilla.UITour.setConfiguration = function(configName, configValue) {
+ _sendEvent('setConfiguration', {
+ configuration: configName,
+ value: configValue,
+ });
+ };
+
+ /**
+ * Request the browser open the Firefox Accounts page.
+ *
+ * @param {Object} extraURLCampaignParams - An object containing additional
+ * paramaters 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, begins with "utm_" and contains only only alphanumeric
+ * characters, dashes or underscores. The values may be any string and will
+ * automatically be encoded.
+ */
+ Mozilla.UITour.showFirefoxAccounts = function(extraURLCampaignParams) {
+ _sendEvent('showFirefoxAccounts', {
+ extraURLCampaignParams: JSON.stringify(extraURLCampaignParams),
+ });
+ };
+
+ Mozilla.UITour.resetFirefox = function() {
+ _sendEvent('resetFirefox');
+ };
+
+ Mozilla.UITour.addNavBarWidget= function(name, callback) {
+ _sendEvent('addNavBarWidget', {
+ name: name,
+ callbackID: _waitForCallback(callback),
+ });
+ };
+
+ Mozilla.UITour.setDefaultSearchEngine = function(identifier) {
+ _sendEvent('setDefaultSearchEngine', {
+ identifier: identifier,
+ });
+ };
+
+ Mozilla.UITour.setTreatmentTag = function(name, value) {
+ _sendEvent('setTreatmentTag', {
+ name: name,
+ value: value
+ });
+ };
+
+ Mozilla.UITour.getTreatmentTag = function(name, callback) {
+ _sendEvent('getTreatmentTag', {
+ name: name,
+ callbackID: _waitForCallback(callback)
+ });
+ };
+
+ Mozilla.UITour.setSearchTerm = function(term) {
+ _sendEvent('setSearchTerm', {
+ term: term
+ });
+ };
+
+ Mozilla.UITour.openSearchPanel = function(callback) {
+ _sendEvent('openSearchPanel', {
+ callbackID: _waitForCallback(callback)
+ });
+ };
+
+ Mozilla.UITour.forceShowReaderIcon = function() {
+ _sendEvent('forceShowReaderIcon');
+ };
+
+ Mozilla.UITour.toggleReaderMode = function() {
+ _sendEvent('toggleReaderMode');
+ };
+
+ Mozilla.UITour.openPreferences = function(pane) {
+ _sendEvent('openPreferences', {
+ pane: pane
+ });
+ };
+
+ /**
+ * 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.
+ *
+ * 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.
+ */
+ Mozilla.UITour.closeTab = function() {
+ _sendEvent('closeTab');
+ };
+})();
+
+// Make this library Require-able.
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = Mozilla.UITour;
+}
diff --git a/browser/components/uitour/UITour.jsm b/browser/components/uitour/UITour.jsm
new file mode 100644
index 000000000..b92715963
--- /dev/null
+++ b/browser/components/uitour/UITour.jsm
@@ -0,0 +1,2111 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["UITour"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource:///modules/RecentWindow.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/TelemetryController.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+
+Cu.importGlobalProperties(["URL"]);
+
+XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
+ "resource://gre/modules/LightweightThemeManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ResetProfile",
+ "resource://gre/modules/ResetProfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
+ "resource:///modules/CustomizableUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
+ "resource://gre/modules/UITelemetry.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry",
+ "resource:///modules/BrowserUITelemetry.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode",
+ "resource://gre/modules/ReaderMode.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ReaderParent",
+ "resource:///modules/ReaderParent.jsm");
+
+// See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
+const PREF_LOG_LEVEL = "browser.uitour.loglevel";
+const PREF_SEENPAGEIDS = "browser.uitour.seenPageIDs";
+const PREF_READERVIEW_TRIGGER = "browser.uitour.readerViewTrigger";
+const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
+
+const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([
+ "forceShowReaderIcon",
+ "getConfiguration",
+ "getTreatmentTag",
+ "hideHighlight",
+ "hideInfo",
+ "hideMenu",
+ "ping",
+ "registerPageID",
+ "setConfiguration",
+ "setTreatmentTag",
+]);
+const MAX_BUTTONS = 4;
+
+const BUCKET_NAME = "UITour";
+const BUCKET_TIMESTEPS = [
+ 1 * 60 * 1000, // Until 1 minute after tab is closed/inactive.
+ 3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive.
+ 10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive.
+ 60 * 60 * 1000, // Until 1 hour after tab is closed/inactive.
+];
+
+// Time after which seen Page IDs expire.
+const SEENPAGEID_EXPIRY = 8 * 7 * 24 * 60 * 60 * 1000; // 8 weeks.
+
+// Prefix for any target matching a search engine.
+const TARGET_SEARCHENGINE_PREFIX = "searchEngine-";
+
+// Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let ConsoleAPI = Cu.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI;
+ let consoleOptions = {
+ maxLogLevelPref: PREF_LOG_LEVEL,
+ prefix: "UITour",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+this.UITour = {
+ url: null,
+ seenPageIDs: null,
+ // This map is not persisted and is used for
+ // building the content source of a potential tour.
+ pageIDsForSession: new Map(),
+ pageIDSourceBrowsers: new WeakMap(),
+ /* Map from browser chrome windows to a Set of <browser>s in which a tour is open (both visible and hidden) */
+ tourBrowsersByWindow: new WeakMap(),
+ appMenuOpenForAnnotation: new Set(),
+ availableTargetsCache: new WeakMap(),
+ clearAvailableTargetsCache() {
+ this.availableTargetsCache = new WeakMap();
+ },
+
+ _annotationPanelMutationObservers: new WeakMap(),
+
+ highlightEffects: ["random", "wobble", "zoom", "color"],
+ targets: new Map([
+ ["accountStatus", {
+ query: (aDocument) => {
+ // If the user is logged in, use the avatar element.
+ let fxAFooter = aDocument.getElementById("PanelUI-footer-fxa");
+ if (fxAFooter.getAttribute("fxastatus")) {
+ return aDocument.getElementById("PanelUI-fxa-avatar");
+ }
+
+ // Otherwise use the sync setup icon.
+ let statusButton = aDocument.getElementById("PanelUI-fxa-label");
+ return aDocument.getAnonymousElementByAttribute(statusButton,
+ "class",
+ "toolbarbutton-icon");
+ },
+ // This is a fake widgetName starting with the "PanelUI-" prefix so we know
+ // to automatically open the appMenu when annotating this target.
+ widgetName: "PanelUI-fxa-label",
+ }],
+ ["addons", {query: "#add-ons-button"}],
+ ["appMenu", {
+ addTargetListener: (aDocument, aCallback) => {
+ let panelPopup = aDocument.getElementById("PanelUI-popup");
+ panelPopup.addEventListener("popupshown", aCallback);
+ },
+ query: "#PanelUI-button",
+ removeTargetListener: (aDocument, aCallback) => {
+ let panelPopup = aDocument.getElementById("PanelUI-popup");
+ panelPopup.removeEventListener("popupshown", aCallback);
+ },
+ }],
+ ["backForward", {
+ query: "#back-button",
+ widgetName: "urlbar-container",
+ }],
+ ["bookmarks", {query: "#bookmarks-menu-button"}],
+ ["controlCenter-trackingUnblock", controlCenterTrackingToggleTarget(true)],
+ ["controlCenter-trackingBlock", controlCenterTrackingToggleTarget(false)],
+ ["customize", {
+ query: (aDocument) => {
+ let customizeButton = aDocument.getElementById("PanelUI-customize");
+ return aDocument.getAnonymousElementByAttribute(customizeButton,
+ "class",
+ "toolbarbutton-icon");
+ },
+ widgetName: "PanelUI-customize",
+ }],
+ ["devtools", {query: "#developer-button"}],
+ ["help", {query: "#PanelUI-help"}],
+ ["home", {query: "#home-button"}],
+ ["forget", {
+ allowAdd: true,
+ query: "#panic-button",
+ widgetName: "panic-button",
+ }],
+ ["pocket", {
+ allowAdd: true,
+ query: "#pocket-button",
+ widgetName: "pocket-button",
+ }],
+ ["privateWindow", {query: "#privatebrowsing-button"}],
+ ["quit", {query: "#PanelUI-quit"}],
+ ["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 aDocument.getAnonymousElementByAttribute(searchbar,
+ "anonid",
+ "searchbar-search-button");
+ },
+ widgetName: "search-container",
+ }],
+ ["searchPrefsLink", {
+ query: (aDocument) => {
+ let element = null;
+ let popup = aDocument.getElementById("PopupSearchAutoComplete");
+ if (popup.state != "open")
+ return null;
+ element = aDocument.getAnonymousElementByAttribute(popup,
+ "anonid",
+ "search-settings");
+ if (!element || !UITour.isElementVisible(element)) {
+ return null;
+ }
+ return element;
+ },
+ }],
+ ["selectedTabIcon", {
+ query: (aDocument) => {
+ let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
+ let element = aDocument.getAnonymousElementByAttribute(selectedtab,
+ "anonid",
+ "tab-icon-image");
+ if (!element || !UITour.isElementVisible(element)) {
+ return null;
+ }
+ return element;
+ },
+ }],
+ ["trackingProtection", {
+ query: "#tracking-protection-icon",
+ }],
+ ["urlbar", {
+ query: "#urlbar",
+ widgetName: "urlbar-container",
+ }],
+ ["webide", {query: "#webide-button"}],
+ ]),
+
+ init: function() {
+ log.debug("Initializing UITour");
+ // Lazy getter is initialized here so it can be replicated any time
+ // in a test.
+ delete this.seenPageIDs;
+ Object.defineProperty(this, "seenPageIDs", {
+ get: this.restoreSeenPageIDs.bind(this),
+ configurable: true,
+ });
+
+ delete this.url;
+ XPCOMUtils.defineLazyGetter(this, "url", function () {
+ return Services.urlFormatter.formatURLPref("browser.uitour.url");
+ });
+
+ // Clear the availableTargetsCache on widget changes.
+ let listenerMethods = [
+ "onWidgetAdded",
+ "onWidgetMoved",
+ "onWidgetRemoved",
+ "onWidgetReset",
+ "onAreaReset",
+ ];
+ CustomizableUI.addListener(listenerMethods.reduce((listener, method) => {
+ listener[method] = () => this.clearAvailableTargetsCache();
+ return listener;
+ }, {}));
+ },
+
+ restoreSeenPageIDs: function() {
+ delete this.seenPageIDs;
+
+ if (UITelemetry.enabled) {
+ let dateThreshold = Date.now() - SEENPAGEID_EXPIRY;
+
+ try {
+ let data = Services.prefs.getCharPref(PREF_SEENPAGEIDS);
+ data = new Map(JSON.parse(data));
+
+ for (let [pageID, details] of data) {
+
+ if (typeof pageID != "string" ||
+ typeof details != "object" ||
+ typeof details.lastSeen != "number" ||
+ details.lastSeen < dateThreshold) {
+
+ data.delete(pageID);
+ }
+ }
+
+ this.seenPageIDs = data;
+ } catch (e) {}
+ }
+
+ if (!this.seenPageIDs)
+ this.seenPageIDs = new Map();
+
+ this.persistSeenIDs();
+
+ return this.seenPageIDs;
+ },
+
+ addSeenPageID: function(aPageID) {
+ if (!UITelemetry.enabled)
+ return;
+
+ this.seenPageIDs.set(aPageID, {
+ lastSeen: Date.now(),
+ });
+
+ this.persistSeenIDs();
+ },
+
+ persistSeenIDs: function() {
+ if (this.seenPageIDs.size === 0) {
+ Services.prefs.clearUserPref(PREF_SEENPAGEIDS);
+ return;
+ }
+
+ Services.prefs.setCharPref(PREF_SEENPAGEIDS,
+ JSON.stringify([...this.seenPageIDs]));
+ },
+
+ get _readerViewTriggerRegEx() {
+ delete this._readerViewTriggerRegEx;
+ let readerViewUITourTrigger = Services.prefs.getCharPref(PREF_READERVIEW_TRIGGER);
+ return this._readerViewTriggerRegEx = new RegExp(readerViewUITourTrigger, "i");
+ },
+
+ onLocationChange: function(aLocation) {
+ // The ReaderView tour page is expected to run in Reader View,
+ // which disables JavaScript on the page. To get around that, we
+ // automatically start a pre-defined tour on page load (for hysterical
+ // raisins the ReaderView tour is known as "readinglist")
+ let originalUrl = ReaderMode.getOriginalUrl(aLocation);
+ if (this._readerViewTriggerRegEx.test(originalUrl)) {
+ this.startSubTour("readinglist");
+ }
+ },
+
+ onPageEvent: function(aMessage, aEvent) {
+ let browser = aMessage.target;
+ 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");
+ }
+
+ let messageManager = browser.messageManager;
+
+ log.debug("onPageEvent:", aEvent.detail, aMessage);
+
+ if (typeof aEvent.detail != "object") {
+ log.warn("Malformed event - detail not an object");
+ return false;
+ }
+
+ let action = aEvent.detail.action;
+ if (typeof action != "string" || !action) {
+ log.warn("Action not defined");
+ return false;
+ }
+
+ let data = aEvent.detail.data;
+ if (typeof data != "object") {
+ log.warn("Malformed event - data not an object");
+ return false;
+ }
+
+ if ((aEvent.pageVisibilityState == "hidden" ||
+ aEvent.pageVisibilityState == "unloaded") &&
+ !BACKGROUND_PAGE_ACTIONS_ALLOWED.has(action)) {
+ log.warn("Ignoring disallowed action from a hidden page:", action);
+ return false;
+ }
+
+ switch (action) {
+ case "registerPageID": {
+ if (typeof data.pageID != "string") {
+ log.warn("registerPageID: pageID must be a string");
+ break;
+ }
+
+ this.pageIDsForSession.set(data.pageID, {lastSeen: Date.now()});
+
+ // The rest is only relevant if Telemetry is enabled.
+ if (!UITelemetry.enabled) {
+ log.debug("registerPageID: Telemetry disabled, not doing anything");
+ break;
+ }
+
+ // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
+ // pageID, as it could make parsing the telemetry bucket name difficult.
+ if (data.pageID.includes(BrowserUITelemetry.BUCKET_SEPARATOR)) {
+ log.warn("registerPageID: Invalid page ID specified");
+ break;
+ }
+
+ this.addSeenPageID(data.pageID);
+ this.pageIDSourceBrowsers.set(browser, data.pageID);
+ this.setTelemetryBucket(data.pageID);
+
+ break;
+ }
+
+ case "showHeartbeat": {
+ // Validate the input parameters.
+ if (typeof data.message !== "string" || data.message === "") {
+ log.error("showHeartbeat: Invalid message specified.");
+ return false;
+ }
+
+ if (typeof data.thankyouMessage !== "string" || data.thankyouMessage === "") {
+ log.error("showHeartbeat: Invalid thank you message specified.");
+ return false;
+ }
+
+ if (typeof data.flowId !== "string" || data.flowId === "") {
+ log.error("showHeartbeat: Invalid flowId specified.");
+ return false;
+ }
+
+ if (data.engagementButtonLabel && typeof data.engagementButtonLabel != "string") {
+ log.error("showHeartbeat: Invalid engagementButtonLabel specified");
+ return false;
+ }
+
+ let heartbeatWindow = window;
+ if (data.privateWindowsOnly && !PrivateBrowsingUtils.isWindowPrivate(heartbeatWindow)) {
+ heartbeatWindow = RecentWindow.getMostRecentBrowserWindow({ private: true });
+ if (!heartbeatWindow) {
+ log.debug("showHeartbeat: No private window found");
+ return false;
+ }
+ }
+
+ // Finally show the Heartbeat UI.
+ this.showHeartbeat(heartbeatWindow, data);
+ break;
+ }
+
+ case "showHighlight": {
+ let targetPromise = this.getTarget(window, data.target);
+ targetPromise.then(target => {
+ if (!target.node) {
+ log.error("UITour: Target could not be resolved: " + data.target);
+ return;
+ }
+ let effect = undefined;
+ if (this.highlightEffects.indexOf(data.effect) !== -1) {
+ effect = data.effect;
+ }
+ this.showHighlight(window, target, effect);
+ }).catch(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) {
+ 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 > 0) {
+ 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(messageManager, 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) {
+ log.warn("showInfo: Reached limit of allowed number of buttons");
+ break;
+ }
+ }
+ }
+ }
+
+ let infoOptions = {};
+ if (typeof data.closeButtonCallbackID == "string") {
+ infoOptions.closeButtonCallback = () => {
+ this.sendPageCallback(messageManager, data.closeButtonCallbackID);
+ };
+ }
+ if (typeof data.targetCallbackID == "string") {
+ infoOptions.targetCallback = details => {
+ this.sendPageCallback(messageManager, data.targetCallbackID, details);
+ };
+ }
+
+ this.showInfo(window, target, data.title, data.text, iconURL, buttons, infoOptions);
+ }).catch(log.error);
+ break;
+ }
+
+ case "hideInfo": {
+ this.hideInfo(window);
+ break;
+ }
+
+ case "previewTheme": {
+ this.previewTheme(data.theme);
+ break;
+ }
+
+ case "resetTheme": {
+ this.resetTheme();
+ break;
+ }
+
+ case "showMenu": {
+ this.showMenu(window, data.name, () => {
+ if (typeof data.showCallbackID == "string")
+ this.sendPageCallback(messageManager, data.showCallbackID);
+ });
+ break;
+ }
+
+ case "hideMenu": {
+ this.hideMenu(window, data.name);
+ break;
+ }
+
+ case "showNewTab": {
+ this.showNewTab(window, browser);
+ break;
+ }
+
+ case "getConfiguration": {
+ if (typeof data.configuration != "string") {
+ log.warn("getConfiguration: No configuration option specified");
+ return false;
+ }
+
+ this.getConfiguration(messageManager, window, data.configuration, data.callbackID);
+ break;
+ }
+
+ case "setConfiguration": {
+ if (typeof data.configuration != "string") {
+ 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") {
+ log.warn("openPreferences: Invalid pane specified");
+ return false;
+ }
+
+ window.openPreferences(data.pane);
+ break;
+ }
+
+ case "showFirefoxAccounts": {
+ // 'signup' is the only action that makes sense currently, so we don't
+ // accept arbitrary actions just to be safe...
+ let p = new URLSearchParams("action=signup&entrypoint=uitour");
+ // Call our helper to validate extraURLCampaignParams and populate URLSearchParams
+ if (!this._populateCampaignParams(p, data.extraURLCampaignParams)) {
+ log.warn("showFirefoxAccounts: invalid campaign args specified");
+ return false;
+ }
+
+ // We want to replace the current tab.
+ browser.loadURI("about:accounts?" + p.toString());
+ break;
+ }
+
+ case "resetFirefox": {
+ // Open a reset profile dialog window.
+ if (ResetProfile.resetSupported()) {
+ 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, messageManager, data.callbackID);
+ }).catch(log.error);
+ break;
+ }
+
+ case "setDefaultSearchEngine": {
+ let enginePromise = this.selectSearchEngine(data.identifier);
+ enginePromise.catch(Cu.reportError);
+ break;
+ }
+
+ case "setTreatmentTag": {
+ let name = data.name;
+ let value = data.value;
+ let string = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ string.data = value;
+ Services.prefs.setComplexValue("browser.uitour.treatment." + name,
+ Ci.nsISupportsString, string);
+ // 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.getComplexValue("browser.uitour.treatment." + name,
+ Ci.nsISupportsString).data;
+ } catch (ex) {}
+ this.sendPageCallback(messageManager, data.callbackID, { value: 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(messageManager, data.callbackID);
+ } else {
+ let onPopupShown = () => {
+ searchbar.textbox.popup.removeEventListener("popupshown", onPopupShown);
+ this.sendPageCallback(messageManager, data.callbackID);
+ };
+
+ searchbar.textbox.popup.addEventListener("popupshown", onPopupShown);
+ searchbar.openSuggestionsPanel();
+ }
+ }).then(null, Cu.reportError);
+ break;
+ }
+
+ case "ping": {
+ if (typeof data.callbackID == "string")
+ this.sendPageCallback(messageManager, data.callbackID);
+ break;
+ }
+
+ case "forceShowReaderIcon": {
+ ReaderParent.forceShowReaderIcon(browser);
+ break;
+ }
+
+ case "toggleReaderMode": {
+ let targetPromise = this.getTarget(window, "readerMode-urlBar");
+ targetPromise.then(target => {
+ ReaderParent.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;
+ }
+ }
+
+ 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", false);
+
+ window.addEventListener("SSWindowClosing", this);
+ },
+
+ handleEvent: function(aEvent) {
+ 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: function(aSubject, aTopic, aData) {
+ 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": {
+ let winEnum = Services.wm.getEnumerator("navigator:browser");
+ while (winEnum.hasMoreElements()) {
+ let window = winEnum.getNext();
+ if (window.closed)
+ continue;
+
+ let tourBrowsers = this.tourBrowsersByWindow.get(window);
+ if (!tourBrowsers)
+ continue;
+
+ for (let browser of tourBrowsers) {
+ let messageManager = browser.messageManager;
+ if (aSubject != messageManager) {
+ continue;
+ }
+
+ this.teardownTourForBrowser(window, browser, true);
+ return;
+ }
+ }
+ break;
+ }
+ }
+ },
+
+ // Given a string that is a JSONified represenation of an object with
+ // additional utm_* URL params that should be appended, validate and append
+ // them to the passed URLSearchParams object. Returns true if the params
+ // were validated and appended, and false if the request should be ignored.
+ _populateCampaignParams: function(urlSearchParams, extraURLCampaignParams) {
+ // We are extra paranoid about what params we allow to be appended.
+ if (typeof extraURLCampaignParams == "undefined") {
+ // no params, so it's all good.
+ return true;
+ }
+ if (typeof extraURLCampaignParams != "string") {
+ log.warn("_populateCampaignParams: extraURLCampaignParams is not a string");
+ return false;
+ }
+ let campaignParams;
+ try {
+ if (extraURLCampaignParams) {
+ campaignParams = JSON.parse(extraURLCampaignParams);
+ if (typeof campaignParams != "object") {
+ log.warn("_populateCampaignParams: extraURLCampaignParams is not a stringified object");
+ return false;
+ }
+ }
+ } catch (ex) {
+ log.warn("_populateCampaignParams: extraURLCampaignParams is not a JSON object");
+ return false;
+ }
+ if (campaignParams) {
+ // 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 campaignParams) {
+ let value = campaignParams[name];
+ if (typeof name != "string" || typeof value != "string" ||
+ !name.startsWith("utm_") ||
+ value.length == 0 ||
+ !reSimpleString.test(name)) {
+ log.warn("_populateCampaignParams: invalid campaign param specified");
+ return false;
+ }
+ urlSearchParams.append(name, value);
+ }
+ }
+ return true;
+ },
+
+ setTelemetryBucket: function(aPageID) {
+ let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
+ BrowserUITelemetry.setBucket(bucket);
+ },
+
+ setExpiringTelemetryBucket: function(aPageID, aType) {
+ let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID +
+ BrowserUITelemetry.BUCKET_SEPARATOR + aType;
+
+ BrowserUITelemetry.setExpiringBucket(bucket,
+ BUCKET_TIMESTEPS);
+ },
+
+ // This is registered with UITelemetry by BrowserUITelemetry, so that UITour
+ // can remain lazy-loaded on-demand.
+ getTelemetry: function() {
+ return {
+ seenPageIDs: [...this.seenPageIDs.keys()],
+ };
+ },
+
+ /**
+ * Tear down a tour from a tab e.g. upon switching/closing tabs.
+ */
+ teardownTourForBrowser: function(aWindow, aBrowser, aTourPageClosing = false) {
+ log.debug("teardownTourForBrowser: aBrowser = ", aBrowser, aTourPageClosing);
+
+ if (this.pageIDSourceBrowsers.has(aBrowser)) {
+ let pageID = this.pageIDSourceBrowsers.get(aBrowser);
+ this.setExpiringTelemetryBucket(pageID, aTourPageClosing ? "closed" : "inactive");
+ }
+
+ let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow);
+ if (aTourPageClosing && openTourBrowsers) {
+ openTourBrowsers.delete(aBrowser);
+ }
+
+ this.hideHighlight(aWindow);
+ this.hideInfo(aWindow);
+ // Ensure the menu panel is hidden before calling recreatePopup so popup events occur.
+ this.hideMenu(aWindow, "appMenu");
+ this.hideMenu(aWindow, "controlCenter");
+
+ // Clean up panel listeners after calling hideMenu above.
+ aWindow.PanelUI.panel.removeEventListener("popuphiding", this.hideAppMenuAnnotations);
+ aWindow.PanelUI.panel.removeEventListener("ViewShowing", this.hideAppMenuAnnotations);
+ aWindow.PanelUI.panel.removeEventListener("popuphidden", this.onPanelHidden);
+ let controlCenterPanel = aWindow.gIdentityHandler._identityPopup;
+ controlCenterPanel.removeEventListener("popuphidden", this.onPanelHidden);
+ controlCenterPanel.removeEventListener("popuphiding", this.hideControlCenterAnnotations);
+
+ this.resetTheme();
+
+ // 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);
+ }
+ },
+
+ /**
+ * Tear down all tours for a ChromeWindow.
+ */
+ teardownTourForWindow: function(aWindow) {
+ log.debug("teardownTourForWindow");
+ aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
+ aWindow.removeEventListener("SSWindowClosing", this);
+
+ let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow);
+ if (openTourBrowsers) {
+ for (let browser of openTourBrowsers) {
+ if (this.pageIDSourceBrowsers.has(browser)) {
+ let pageID = this.pageIDSourceBrowsers.get(browser);
+ this.setExpiringTelemetryBucket(pageID, "closed");
+ }
+ }
+ }
+
+ this.tourBrowsersByWindow.delete(aWindow);
+ },
+
+ // This function is copied to UITourListener.
+ isSafeScheme: function(aURI) {
+ let allowedSchemes = new Set(["https", "about"]);
+ if (!Services.prefs.getBoolPref("browser.uitour.requireSecure"))
+ allowedSchemes.add("http");
+
+ if (!allowedSchemes.has(aURI.scheme)) {
+ log.error("Unsafe scheme:", aURI.scheme);
+ return false;
+ }
+
+ return true;
+ },
+
+ resolveURL: function(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: function(aMessageManager, aCallbackID, aData = {}) {
+ let detail = {data: aData, callbackID: aCallbackID};
+ log.debug("sendPageCallback", detail);
+ aMessageManager.sendAsyncMessage("UITour:SendPageCallback", detail);
+ },
+
+ isElementVisible: function(aElement) {
+ let targetStyle = aElement.ownerGlobal.getComputedStyle(aElement);
+ return !aElement.ownerDocument.hidden &&
+ targetStyle.display != "none" &&
+ targetStyle.visibility == "visible";
+ },
+
+ getTarget: function(aWindow, aTargetName, aSticky = false) {
+ log.debug("getTarget:", aTargetName);
+ let deferred = Promise.defer();
+ if (typeof aTargetName != "string" || !aTargetName) {
+ log.warn("getTarget: Invalid target name specified");
+ deferred.reject("Invalid target name specified");
+ return deferred.promise;
+ }
+
+ let targetObject = this.targets.get(aTargetName);
+ if (!targetObject) {
+ log.warn("getTarget: The specified target name is not in the allowed set");
+ deferred.reject("The specified target name is not in the allowed set");
+ return deferred.promise;
+ }
+
+ let targetQuery = targetObject.query;
+ aWindow.PanelUI.ensureReady().then(() => {
+ let node;
+ if (typeof targetQuery == "function") {
+ try {
+ node = targetQuery(aWindow.document);
+ } catch (ex) {
+ log.warn("getTarget: Error running target query:", ex);
+ node = null;
+ }
+ } else {
+ node = aWindow.document.querySelector(targetQuery);
+ }
+
+ deferred.resolve({
+ addTargetListener: targetObject.addTargetListener,
+ infoPanelOffsetX: targetObject.infoPanelOffsetX,
+ infoPanelOffsetY: targetObject.infoPanelOffsetY,
+ infoPanelPosition: targetObject.infoPanelPosition,
+ node: node,
+ removeTargetListener: targetObject.removeTargetListener,
+ targetName: aTargetName,
+ widgetName: targetObject.widgetName,
+ allowAdd: targetObject.allowAdd,
+ });
+ }).catch(log.error);
+ return deferred.promise;
+ },
+
+ targetIsInAppMenu: function(aTarget) {
+ let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
+ if (placement && placement.area == CustomizableUI.AREA_PANEL) {
+ return true;
+ }
+
+ let targetElement = aTarget.node;
+ // Use the widget for filtering if it exists since the target may be the icon inside.
+ if (aTarget.widgetName) {
+ targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName);
+ }
+
+ // Handle the non-customizable buttons at the bottom of the menu which aren't proper widgets.
+ return targetElement.id.startsWith("PanelUI-")
+ && targetElement.id != "PanelUI-button";
+ },
+
+ /**
+ * Called before opening or after closing a highlight or info panel to see if
+ * we need to open or close the appMenu to see the annotation's anchor.
+ */
+ _setAppMenuStateForAnnotation: function(aWindow, aAnnotationType, aShouldOpenForHighlight, aCallback = null) {
+ log.debug("_setAppMenuStateForAnnotation:", aAnnotationType);
+ log.debug("_setAppMenuStateForAnnotation: Menu is expected to be:", aShouldOpenForHighlight ? "open" : "closed");
+
+ // If the panel is in the desired state, we're done.
+ let panelIsOpen = aWindow.PanelUI.panel.state != "closed";
+ if (aShouldOpenForHighlight == panelIsOpen) {
+ log.debug("_setAppMenuStateForAnnotation: Panel already in expected state");
+ if (aCallback)
+ aCallback();
+ return;
+ }
+
+ // Don't close the menu if it wasn't opened by us (e.g. via showmenu instead).
+ if (!aShouldOpenForHighlight && !this.appMenuOpenForAnnotation.has(aAnnotationType)) {
+ log.debug("_setAppMenuStateForAnnotation: Menu not opened by us, not closing");
+ if (aCallback)
+ aCallback();
+ return;
+ }
+
+ if (aShouldOpenForHighlight) {
+ this.appMenuOpenForAnnotation.add(aAnnotationType);
+ } else {
+ this.appMenuOpenForAnnotation.delete(aAnnotationType);
+ }
+
+ // Actually show or hide the menu
+ if (this.appMenuOpenForAnnotation.size) {
+ log.debug("_setAppMenuStateForAnnotation: Opening the menu");
+ this.showMenu(aWindow, "appMenu", aCallback);
+ } else {
+ log.debug("_setAppMenuStateForAnnotation: Closing the menu");
+ this.hideMenu(aWindow, "appMenu");
+ if (aCallback)
+ aCallback();
+ }
+
+ },
+
+ previewTheme: function(aTheme) {
+ let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin");
+ let data = LightweightThemeManager.parseTheme(aTheme, origin);
+ if (data)
+ LightweightThemeManager.previewTheme(data);
+ },
+
+ resetTheme: function() {
+ LightweightThemeManager.resetPreview();
+ },
+
+ /**
+ * Show the Heartbeat UI to request user feedback. This function reports back to the
+ * caller using |notify|. The notification event name reflects the current status the UI
+ * is in (either "Heartbeat:NotificationOffered", "Heartbeat:NotificationClosed",
+ * "Heartbeat:LearnMore", "Heartbeat:Engaged", "Heartbeat:Voted",
+ * "Heartbeat:SurveyExpired" or "Heartbeat:WindowClosed").
+ * When a "Heartbeat:Voted" event is notified
+ * the data payload contains a |score| field which holds the rating picked by the user.
+ * Please note that input parameters are already validated by the caller.
+ *
+ * @param aChromeWindow
+ * The chrome window that the heartbeat notification is displayed in.
+ * @param {Object} aOptions Options object.
+ * @param {String} aOptions.message
+ * The message, or question, to display on the notification.
+ * @param {String} aOptions.thankyouMessage
+ * The thank you message to display after user votes.
+ * @param {String} aOptions.flowId
+ * An identifier for this rating flow. Please note that this is only used to
+ * identify the notification box.
+ * @param {String} [aOptions.engagementButtonLabel=null]
+ * The text of the engagement button to use instad of stars. If this is null
+ * or invalid, rating stars are used.
+ * @param {String} [aOptions.engagementURL=null]
+ * The engagement URL to open in a new tab once user has engaged. If this is null
+ * or invalid, no new tab is opened.
+ * @param {String} [aOptions.learnMoreLabel=null]
+ * The label of the learn more link. No link will be shown if this is null.
+ * @param {String} [aOptions.learnMoreURL=null]
+ * The learn more URL to open when clicking on the learn more link. No learn more
+ * will be shown if this is an invalid URL.
+ * @param {boolean} [aOptions.privateWindowsOnly=false]
+ * Whether the heartbeat UI should only be targeted at a private window (if one exists).
+ * No notifications should be fired when this is true.
+ * @param {String} [aOptions.surveyId]
+ * An ID for the survey, reflected in the Telemetry ping.
+ * @param {Number} [aOptions.surveyVersion]
+ * Survey's version number, reflected in the Telemetry ping.
+ * @param {boolean} [aOptions.testing]
+ * Whether this is a test survey, reflected in the Telemetry ping.
+ */
+ showHeartbeat(aChromeWindow, aOptions) {
+ // Initialize survey state
+ let pingSent = false;
+ let surveyResults = {};
+ let surveyEndTimer = null;
+
+ /**
+ * Accumulates survey events and submits to Telemetry after the survey ends.
+ *
+ * @param {String} aEventName
+ * Heartbeat event name
+ * @param {Object} aParams
+ * Additional parameters and their values
+ */
+ let maybeNotifyHeartbeat = (aEventName, aParams = {}) => {
+ // Return if event occurred after the ping was sent
+ if (pingSent) {
+ log.warn("maybeNotifyHeartbeat: event occurred after ping sent:", aEventName, aParams);
+ return;
+ }
+
+ // No Telemetry from private-window-only Heartbeats
+ if (aOptions.privateWindowsOnly) {
+ return;
+ }
+
+ let ts = Date.now();
+ let sendPing = false;
+ switch (aEventName) {
+ case "Heartbeat:NotificationOffered":
+ surveyResults.flowId = aOptions.flowId;
+ surveyResults.offeredTS = ts;
+ break;
+ case "Heartbeat:LearnMore":
+ // record only the first click
+ if (!surveyResults.learnMoreTS) {
+ surveyResults.learnMoreTS = ts;
+ }
+ break;
+ case "Heartbeat:Engaged":
+ surveyResults.engagedTS = ts;
+ break;
+ case "Heartbeat:Voted":
+ surveyResults.votedTS = ts;
+ surveyResults.score = aParams.score;
+ break;
+ case "Heartbeat:SurveyExpired":
+ surveyResults.expiredTS = ts;
+ break;
+ case "Heartbeat:NotificationClosed":
+ // this is the final event in most surveys
+ surveyResults.closedTS = ts;
+ sendPing = true;
+ break;
+ case "Heartbeat:WindowClosed":
+ surveyResults.windowClosedTS = ts;
+ sendPing = true;
+ break;
+ default:
+ log.error("maybeNotifyHeartbeat: unrecognized event:", aEventName);
+ break;
+ }
+
+ aParams.timestamp = ts;
+ aParams.flowId = aOptions.flowId;
+ this.notify(aEventName, aParams);
+
+ if (!sendPing) {
+ return;
+ }
+
+ // Send the ping to Telemetry
+ let payload = Object.assign({}, surveyResults);
+ payload.version = 1;
+ for (let meta of ["surveyId", "surveyVersion", "testing"]) {
+ if (aOptions.hasOwnProperty(meta)) {
+ payload[meta] = aOptions[meta];
+ }
+ }
+
+ log.debug("Sending payload to Telemetry: aEventName:", aEventName,
+ "payload:", payload);
+
+ TelemetryController.submitExternalPing("heartbeat", payload, {
+ addClientId: true,
+ addEnvironment: true,
+ });
+
+ // only for testing
+ this.notify("Heartbeat:TelemetrySent", payload);
+
+ // Survey is complete, clear out the expiry timer & survey configuration
+ if (surveyEndTimer) {
+ clearTimeout(surveyEndTimer);
+ surveyEndTimer = null;
+ }
+
+ pingSent = true;
+ surveyResults = {};
+ };
+
+ let nb = aChromeWindow.document.getElementById("high-priority-global-notificationbox");
+ let buttons = null;
+
+ if (aOptions.engagementButtonLabel) {
+ buttons = [{
+ label: aOptions.engagementButtonLabel,
+ callback: () => {
+ // Let the consumer know user engaged.
+ maybeNotifyHeartbeat("Heartbeat:Engaged");
+
+ userEngaged(new Map([
+ ["type", "button"],
+ ["flowid", aOptions.flowId]
+ ]));
+
+ // Return true so that the notification bar doesn't close itself since
+ // we have a thank you message to show.
+ return true;
+ },
+ }];
+ }
+
+ let defaultIcon = "chrome://browser/skin/heartbeat-icon.svg";
+ let iconURL = defaultIcon;
+ try {
+ // Take the optional icon URL if specified
+ if (aOptions.iconURL) {
+ iconURL = new URL(aOptions.iconURL);
+ // For now, only allow chrome URIs.
+ if (iconURL.protocol != "chrome:") {
+ iconURL = defaultIcon;
+ throw new Error("Invalid protocol");
+ }
+ }
+ } catch (error) {
+ log.error("showHeartbeat: Invalid icon URL specified.");
+ }
+
+ // Create the notification. Prefix its ID to decrease the chances of collisions.
+ let notice = nb.appendNotification(aOptions.message, "heartbeat-" + aOptions.flowId,
+ iconURL,
+ nb.PRIORITY_INFO_HIGH, buttons,
+ (aEventType) => {
+ if (aEventType != "removed") {
+ return;
+ }
+ // Let the consumer know the notification bar was closed.
+ // This also happens after voting.
+ maybeNotifyHeartbeat("Heartbeat:NotificationClosed");
+ });
+
+ // Get the elements we need to style.
+ let messageImage =
+ aChromeWindow.document.getAnonymousElementByAttribute(notice, "anonid", "messageImage");
+ let messageText =
+ aChromeWindow.document.getAnonymousElementByAttribute(notice, "anonid", "messageText");
+
+ function userEngaged(aEngagementParams) {
+ // Make the heartbeat icon pulse twice.
+ notice.label = aOptions.thankyouMessage;
+ messageImage.classList.remove("pulse-onshow");
+ messageImage.classList.add("pulse-twice");
+
+ // Remove all the children of the notice (rating container
+ // and the flex).
+ while (notice.firstChild) {
+ notice.removeChild(notice.firstChild);
+ }
+
+ // Make sure that we have a valid URL. If we haven't, do not open the engagement page.
+ let engagementURL = null;
+ try {
+ engagementURL = new URL(aOptions.engagementURL);
+ } catch (error) {
+ log.error("showHeartbeat: Invalid URL specified.");
+ }
+
+ // Just open the engagement tab if we have a valid engagement URL.
+ if (engagementURL) {
+ for (let [param, value] of aEngagementParams) {
+ engagementURL.searchParams.append(param, value);
+ }
+
+ // Open the engagement URL in a new tab.
+ aChromeWindow.gBrowser.selectedTab =
+ aChromeWindow.gBrowser.addTab(engagementURL.toString(), {
+ owner: aChromeWindow.gBrowser.selectedTab,
+ relatedToCurrent: true
+ });
+ }
+
+ // Remove the notification bar after 3 seconds.
+ aChromeWindow.setTimeout(() => {
+ nb.removeNotification(notice);
+ }, 3000);
+ }
+
+ // Create the fragment holding the rating UI.
+ let frag = aChromeWindow.document.createDocumentFragment();
+
+ // Build the Heartbeat star rating.
+ const numStars = aOptions.engagementButtonLabel ? 0 : 5;
+ let ratingContainer = aChromeWindow.document.createElement("hbox");
+ ratingContainer.id = "star-rating-container";
+
+ for (let i = 0; i < numStars; i++) {
+ // Create a star rating element.
+ let ratingElement = aChromeWindow.document.createElement("toolbarbutton");
+
+ // Style it.
+ let starIndex = numStars - i;
+ ratingElement.className = "plain star-x";
+ ratingElement.id = "star" + starIndex;
+ ratingElement.setAttribute("data-score", starIndex);
+
+ // Add the click handler.
+ ratingElement.addEventListener("click", function (evt) {
+ let rating = Number(evt.target.getAttribute("data-score"), 10);
+
+ // Let the consumer know user voted.
+ maybeNotifyHeartbeat("Heartbeat:Voted", { score: rating });
+
+ // Append the score data to the engagement URL.
+ userEngaged(new Map([
+ ["type", "stars"],
+ ["score", rating],
+ ["flowid", aOptions.flowId]
+ ]));
+ }.bind(this));
+
+ // Add it to the container.
+ ratingContainer.appendChild(ratingElement);
+ }
+
+ frag.appendChild(ratingContainer);
+
+ // Make sure the stars are not pushed to the right by the spacer.
+ let rightSpacer = aChromeWindow.document.createElement("spacer");
+ rightSpacer.flex = 20;
+ frag.appendChild(rightSpacer);
+
+ messageText.flex = 0; // Collapse the space before the stars.
+ let leftSpacer = messageText.nextSibling;
+ leftSpacer.flex = 0;
+
+ // Make sure that we have a valid learn more URL.
+ let learnMoreURL = null;
+ try {
+ learnMoreURL = new URL(aOptions.learnMoreURL);
+ } catch (error) {
+ log.error("showHeartbeat: Invalid learnMore URL specified.");
+ }
+
+ // Add the learn more link.
+ if (aOptions.learnMoreLabel && learnMoreURL) {
+ let learnMore = aChromeWindow.document.createElement("label");
+ learnMore.className = "text-link";
+ learnMore.href = learnMoreURL.toString();
+ learnMore.setAttribute("value", aOptions.learnMoreLabel);
+ learnMore.addEventListener("click", () => maybeNotifyHeartbeat("Heartbeat:LearnMore"));
+ frag.appendChild(learnMore);
+ }
+
+ // Append the fragment and apply the styling.
+ notice.appendChild(frag);
+ notice.classList.add("heartbeat");
+ messageImage.classList.add("heartbeat", "pulse-onshow");
+ messageText.classList.add("heartbeat");
+
+ // Let the consumer know the notification was shown.
+ maybeNotifyHeartbeat("Heartbeat:NotificationOffered");
+
+ // End the survey if the user quits, closes the window, or
+ // hasn't responded before expiration.
+ if (!aOptions.privateWindowsOnly) {
+ function handleWindowClosed(aTopic) {
+ maybeNotifyHeartbeat("Heartbeat:WindowClosed");
+ aChromeWindow.removeEventListener("SSWindowClosing", handleWindowClosed);
+ }
+ aChromeWindow.addEventListener("SSWindowClosing", handleWindowClosed);
+
+ let surveyDuration = Services.prefs.getIntPref(PREF_SURVEY_DURATION) * 1000;
+ surveyEndTimer = setTimeout(() => {
+ maybeNotifyHeartbeat("Heartbeat:SurveyExpired");
+ nb.removeNotification(notice);
+ }, surveyDuration);
+ }
+ },
+
+ /**
+ * 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 {Node} aAnchor The element that's supposed to be the anchor
+ * @type {Node}
+ */
+ _correctAnchor: function(aAnchor) {
+ // If the target is in the overflow panel, just return the overflow button.
+ if (aAnchor.getAttribute("overflowedItem")) {
+ let doc = aAnchor.ownerDocument;
+ let placement = CustomizableUI.getPlacementOfWidget(aAnchor.id);
+ let areaNode = doc.getElementById(placement.area);
+ return areaNode.overflowable._chevron;
+ }
+
+ return aAnchor;
+ },
+
+ /**
+ * @param aChromeWindow The chrome window that the highlight is in. Necessary since some targets
+ * are in a sub-frame so the defaultView is not the same as the chrome
+ * window.
+ * @param aTarget The element to highlight.
+ * @param aEffect (optional) The effect to use from UITour.highlightEffects or "none".
+ * @see UITour.highlightEffects
+ */
+ showHighlight: function(aChromeWindow, aTarget, aEffect = "none") {
+ function showHighlightPanel() {
+ let highlighter = aChromeWindow.document.getElementById("UITourHighlight");
+
+ 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 = this._correctAnchor(aTarget.node);
+ let targetRect = highlightAnchor.getBoundingClientRect();
+ let highlightHeight = targetRect.height;
+ let highlightWidth = targetRect.width;
+ let minDimension = Math.min(highlightHeight, highlightWidth);
+ let maxDimension = Math.max(highlightHeight, highlightWidth);
+
+ // If the dimensions are within 200% of each other (to include the bookmarks button),
+ // make the highlight a circle with the largest dimension as the diameter.
+ if (maxDimension / minDimension <= 3.0) {
+ highlightHeight = highlightWidth = maxDimension;
+ highlighter.style.borderRadius = "100%";
+ } else {
+ highlighter.style.borderRadius = "";
+ }
+
+ 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") {
+ 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 containerStyle = highlightWindow.getComputedStyle(highlighter.parentElement);
+ let paddingTopPx = 0 - parseFloat(containerStyle.paddingTop);
+ let paddingLeftPx = 0 - parseFloat(containerStyle.paddingLeft);
+ let highlightStyle = highlightWindow.getComputedStyle(highlighter);
+ let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight));
+ let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth));
+ let offsetX = paddingTopPx
+ - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2);
+ let offsetY = paddingLeftPx
+ - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2);
+ this._addAnnotationPanelMutationObserver(highlighter.parentElement);
+ highlighter.parentElement.openPopup(highlightAnchor, "overlap", offsetX, offsetY);
+ }
+
+ // Prevent showing a panel at an undefined position.
+ if (!this.isElementVisible(aTarget.node)) {
+ log.warn("showHighlight: Not showing a highlight since the target isn't visible", aTarget);
+ return;
+ }
+
+ this._setAppMenuStateForAnnotation(aChromeWindow, "highlight",
+ this.targetIsInAppMenu(aTarget),
+ showHighlightPanel.bind(this));
+ },
+
+ hideHighlight: function(aWindow) {
+ let highlighter = aWindow.document.getElementById("UITourHighlight");
+ this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
+ highlighter.parentElement.hidePopup();
+ highlighter.removeAttribute("active");
+
+ this._setAppMenuStateForAnnotation(aWindow, "highlight", 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]
+ */
+ showInfo(aChromeWindow, aAnchor, aTitle = "", aDescription = "",
+ aIconURL = "", aButtons = [], aOptions = {}) {
+ function showInfoPanel(aAnchorEl) {
+ aAnchorEl.focus();
+
+ let document = aChromeWindow.document;
+ let tooltip = document.getElementById("UITourTooltip");
+ 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.createElement(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 tooltipHiding(event) {
+ tooltip.removeEventListener("popuphiding", tooltipHiding);
+ tooltipClose.removeEventListener("command", closeButtonCallback);
+ if (aOptions.targetCallback && aAnchor.removeTargetListener) {
+ aAnchor.removeTargetListener(document, targetCallback);
+ }
+ });
+
+ tooltip.setAttribute("targetName", aAnchor.targetName);
+ tooltip.hidden = false;
+ let alignment = "bottomcenter 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 endModalStateHandler() {
+ document.defaultView.removeEventListener("endmodalstate", endModalStateHandler);
+ tooltip.openPopup(aAnchorEl, alignment);
+ }, false);
+ }
+ }
+
+ // Prevent showing a panel at an undefined position.
+ if (!this.isElementVisible(aAnchor.node)) {
+ log.warn("showInfo: Not showing since the target isn't visible", aAnchor);
+ return;
+ }
+
+ this._setAppMenuStateForAnnotation(aChromeWindow, "info",
+ this.targetIsInAppMenu(aAnchor),
+ showInfoPanel.bind(this, this._correctAnchor(aAnchor.node)));
+ },
+
+ isInfoOnTarget(aChromeWindow, aTargetName) {
+ let document = aChromeWindow.document;
+ let tooltip = document.getElementById("UITourTooltip");
+ return tooltip.getAttribute("targetName") == aTargetName && tooltip.state != "closed";
+ },
+
+ hideInfo: function(aWindow) {
+ let document = aWindow.document;
+
+ let tooltip = document.getElementById("UITourTooltip");
+ this._removeAnnotationPanelMutationObserver(tooltip);
+ tooltip.hidePopup();
+ this._setAppMenuStateForAnnotation(aWindow, "info", false);
+
+ let tooltipButtons = document.getElementById("UITourTooltipButtons");
+ while (tooltipButtons.firstChild)
+ tooltipButtons.firstChild.remove();
+ },
+
+ showMenu: function(aWindow, aMenuName, aOpenCallback = null) {
+ log.debug("showMenu:", aMenuName);
+ function openMenuButton(aMenuBtn) {
+ if (!aMenuBtn || !aMenuBtn.boxObject || aMenuBtn.open) {
+ if (aOpenCallback)
+ aOpenCallback();
+ return;
+ }
+ if (aOpenCallback)
+ aMenuBtn.addEventListener("popupshown", onPopupShown);
+ aMenuBtn.boxObject.openMenu(true);
+ }
+ function onPopupShown(event) {
+ this.removeEventListener("popupshown", onPopupShown);
+ aOpenCallback(event);
+ }
+
+ if (aMenuName == "appMenu") {
+ aWindow.PanelUI.panel.setAttribute("noautohide", "true");
+ // If the popup is already opened, don't recreate the widget as it may cause a flicker.
+ if (aWindow.PanelUI.panel.state != "open") {
+ this.recreatePopup(aWindow.PanelUI.panel);
+ }
+ aWindow.PanelUI.panel.addEventListener("popuphiding", this.hideAppMenuAnnotations);
+ aWindow.PanelUI.panel.addEventListener("ViewShowing", this.hideAppMenuAnnotations);
+ aWindow.PanelUI.panel.addEventListener("popuphidden", this.onPanelHidden);
+ if (aOpenCallback) {
+ aWindow.PanelUI.panel.addEventListener("popupshown", onPopupShown);
+ }
+ aWindow.PanelUI.show();
+ } else if (aMenuName == "bookmarks") {
+ let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
+ openMenuButton(menuBtn);
+ } else if (aMenuName == "controlCenter") {
+ let popup = aWindow.gIdentityHandler._identityPopup;
+
+ // Add the listener even if the panel is already open since it will still
+ // only get registered once even if it was UITour that opened it.
+ popup.addEventListener("popuphiding", this.hideControlCenterAnnotations);
+ popup.addEventListener("popuphidden", this.onPanelHidden);
+
+ popup.setAttribute("noautohide", true);
+ this.clearAvailableTargetsCache();
+
+ if (popup.state == "open") {
+ if (aOpenCallback) {
+ aOpenCallback();
+ }
+ return;
+ }
+
+ this.recreatePopup(popup);
+
+ // Open the control center
+ if (aOpenCallback) {
+ popup.addEventListener("popupshown", onPopupShown);
+ }
+ aWindow.document.getElementById("identity-box").click();
+ } else if (aMenuName == "pocket") {
+ this.getTarget(aWindow, "pocket").then(Task.async(function* onPocketTarget(target) {
+ let widgetGroupWrapper = CustomizableUI.getWidget(target.widgetName);
+ if (widgetGroupWrapper.type != "view" || !widgetGroupWrapper.viewId) {
+ log.error("Can't open the pocket menu without a view");
+ return;
+ }
+ let placement = CustomizableUI.getPlacementOfWidget(target.widgetName);
+ if (!placement || !placement.area) {
+ log.error("Can't open the pocket menu without a placement");
+ return;
+ }
+
+ if (placement.area == CustomizableUI.AREA_PANEL) {
+ // Open the appMenu and wait for it if it's not already opened or showing a subview.
+ yield new Promise((resolve, reject) => {
+ if (aWindow.PanelUI.panel.state != "closed") {
+ if (aWindow.PanelUI.multiView.showingSubView) {
+ reject("A subview is already showing");
+ return;
+ }
+
+ resolve();
+ return;
+ }
+
+ aWindow.PanelUI.panel.addEventListener("popupshown", function onShown() {
+ aWindow.PanelUI.panel.removeEventListener("popupshown", onShown);
+ resolve();
+ });
+
+ aWindow.PanelUI.show();
+ });
+ }
+
+ let widgetWrapper = widgetGroupWrapper.forWindow(aWindow);
+ aWindow.PanelUI.showSubView(widgetGroupWrapper.viewId,
+ widgetWrapper.anchor,
+ placement.area);
+ })).catch(log.error);
+ }
+ },
+
+ hideMenu: function(aWindow, aMenuName) {
+ log.debug("hideMenu:", aMenuName);
+ function closeMenuButton(aMenuBtn) {
+ if (aMenuBtn && aMenuBtn.boxObject)
+ aMenuBtn.boxObject.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 == "controlCenter") {
+ let panel = aWindow.gIdentityHandler._identityPopup;
+ panel.hidePopup();
+ }
+ },
+
+ showNewTab: function(aWindow, aBrowser) {
+ aWindow.openLinkIn("about:newtab", "current", {targetBrowser: aBrowser});
+ },
+
+ hideAnnotationsForPanel: function(aEvent, aTargetPositionCallback) {
+ let win = aEvent.target.ownerGlobal;
+ let annotationElements = new Map([
+ // [annotationElement (panel), method to hide the annotation]
+ [win.document.getElementById("UITourHighlightContainer"), UITour.hideHighlight.bind(UITour)],
+ [win.document.getElementById("UITourTooltip"), UITour.hideInfo.bind(UITour)],
+ ]);
+ 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(log.error);
+ }
+ });
+ UITour.appMenuOpenForAnnotation.clear();
+ },
+
+ hideAppMenuAnnotations: function(aEvent) {
+ UITour.hideAnnotationsForPanel(aEvent, UITour.targetIsInAppMenu);
+ },
+
+ hideControlCenterAnnotations(aEvent) {
+ UITour.hideAnnotationsForPanel(aEvent, (aTarget) => {
+ return aTarget.targetName.startsWith("controlCenter-");
+ });
+ },
+
+ onPanelHidden: function(aEvent) {
+ aEvent.target.removeAttribute("noautohide");
+ UITour.recreatePopup(aEvent.target);
+ UITour.clearAvailableTargetsCache();
+ },
+
+ recreatePopup: function(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: function(aMessageManager, aWindow, aConfiguration, aCallbackID) {
+ switch (aConfiguration) {
+ case "appinfo":
+ let props = ["defaultUpdateChannel", "version"];
+ let appinfo = {};
+ props.forEach(property => appinfo[property] = Services.appinfo[property]);
+
+ // 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 = "default";
+ try {
+ distribution = Services.prefs.getDefaultBranch("distribution.").getCharPref("id");
+ } catch (e) {}
+ appinfo["distribution"] = distribution;
+
+ let isDefaultBrowser = null;
+ try {
+ let shell = aWindow.getShellService();
+ if (shell) {
+ isDefaultBrowser = shell.isDefaultBrowser(false);
+ }
+ } catch (e) {}
+ appinfo["defaultBrowser"] = isDefaultBrowser;
+
+ let canSetDefaultBrowserInBackground = true;
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2") ||
+ AppConstants.isPlatformAndVersionAtLeast("macosx", "10.10")) {
+ canSetDefaultBrowserInBackground = false;
+ } else if (AppConstants.platform == "linux") {
+ // The ShellService may not exist on some versions of Linux.
+ try {
+ aWindow.getShellService();
+ } catch (e) {
+ canSetDefaultBrowserInBackground = null;
+ }
+ }
+
+ appinfo["canSetDefaultBrowserInBackground"] =
+ canSetDefaultBrowserInBackground;
+
+ this.sendPageCallback(aMessageManager, aCallbackID, appinfo);
+ break;
+ case "availableTargets":
+ this.getAvailableTargets(aMessageManager, aWindow, aCallbackID);
+ break;
+ case "search":
+ case "selectedSearchEngine":
+ Services.search.init(rv => {
+ let data;
+ if (Components.isSuccessCode(rv)) {
+ let engines = Services.search.getVisibleEngines();
+ data = {
+ searchEngineIdentifier: Services.search.defaultEngine.identifier,
+ engines: engines.filter((engine) => engine.identifier)
+ .map((engine) => TARGET_SEARCHENGINE_PREFIX + engine.identifier)
+ };
+ } else {
+ data = {engines: [], searchEngineIdentifier: ""};
+ }
+ this.sendPageCallback(aMessageManager, aCallbackID, data);
+ });
+ break;
+ case "sync":
+ this.sendPageCallback(aMessageManager, aCallbackID, {
+ setup: Services.prefs.prefHasUserValue("services.sync.username"),
+ desktopDevices: Preferences.get("services.sync.clients.devices.desktop", 0),
+ mobileDevices: Preferences.get("services.sync.clients.devices.mobile", 0),
+ totalDevices: Preferences.get("services.sync.numClients", 0),
+ });
+ break;
+ case "canReset":
+ this.sendPageCallback(aMessageManager, aCallbackID, ResetProfile.resetSupported());
+ break;
+ default:
+ log.error("getConfiguration: Unknown configuration requested: " + aConfiguration);
+ break;
+ }
+ },
+
+ setConfiguration: function(aWindow, aConfiguration, aValue) {
+ switch (aConfiguration) {
+ case "defaultBrowser":
+ // Ignore aValue in this case because the default browser can only
+ // be set, not unset.
+ try {
+ let shell = aWindow.getShellService();
+ if (shell) {
+ shell.setDefaultBrowser(true, false);
+ }
+ } catch (e) {}
+ break;
+ default:
+ log.error("setConfiguration: Unknown configuration requested: " + aConfiguration);
+ break;
+ }
+ },
+
+ getAvailableTargets: function(aMessageManager, aChromeWindow, aCallbackID) {
+ Task.spawn(function*() {
+ let window = aChromeWindow;
+ let data = this.availableTargetsCache.get(window);
+ if (data) {
+ log.debug("getAvailableTargets: Using cached targets list", data.targets.join(","));
+ this.sendPageCallback(aMessageManager, aCallbackID, data);
+ return;
+ }
+
+ let promises = [];
+ for (let targetName of this.targets.keys()) {
+ promises.push(this.getTarget(window, targetName));
+ }
+ let targetObjects = yield 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(aMessageManager, aCallbackID, data);
+ }.bind(this)).catch(err => {
+ log.error(err);
+ this.sendPageCallback(aMessageManager, aCallbackID, {
+ targets: [],
+ });
+ });
+ },
+
+ startSubTour: function (aFeature) {
+ if (aFeature != "string") {
+ log.error("startSubTour: No feature option specified");
+ return;
+ }
+
+ if (aFeature == "readinglist") {
+ ReaderParent.showReaderModeInfoPanel(browser);
+ } else {
+ log.error("startSubTour: Unknown feature option specified");
+ return;
+ }
+ },
+
+ addNavBarWidget: function (aTarget, aMessageManager, aCallbackID) {
+ if (aTarget.node) {
+ log.error("addNavBarWidget: can't add a widget already present:", aTarget);
+ return;
+ }
+ if (!aTarget.allowAdd) {
+ log.error("addNavBarWidget: not allowed to add this widget:", aTarget);
+ return;
+ }
+ if (!aTarget.widgetName) {
+ log.error("addNavBarWidget: can't add a widget without a widgetName property:", aTarget);
+ return;
+ }
+
+ CustomizableUI.addWidgetToArea(aTarget.widgetName, CustomizableUI.AREA_NAVBAR);
+ this.sendPageCallback(aMessageManager, aCallbackID);
+ },
+
+ _addAnnotationPanelMutationObserver: function(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: function(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: function(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.init((rv) => {
+ if (!Components.isSuccessCode(rv)) {
+ reject("selectSearchEngine: search service init failed: " + rv);
+ return;
+ }
+
+ let engines = Services.search.getVisibleEngines();
+ for (let engine of engines) {
+ if (engine.identifier == aID) {
+ Services.search.defaultEngine = engine;
+ resolve();
+ return;
+ }
+ }
+ reject("selectSearchEngine could not find engine with given ID");
+ return;
+ });
+ });
+ },
+
+ notify(eventName, params) {
+ let winEnum = Services.wm.getEnumerator("navigator:browser");
+ while (winEnum.hasMoreElements()) {
+ let window = winEnum.getNext();
+ if (window.closed)
+ continue;
+
+ let openTourBrowsers = this.tourBrowsersByWindow.get(window);
+ if (!openTourBrowsers)
+ continue;
+
+ for (let browser of openTourBrowsers) {
+ let messageManager = browser.messageManager;
+ if (!messageManager) {
+ log.error("notify: Trying to notify a browser without a messageManager", browser);
+ continue;
+ }
+ let detail = {
+ event: eventName,
+ params: params,
+ };
+ messageManager.sendAsyncMessage("UITour:SendPageNotification", detail);
+ }
+ }
+ },
+};
+
+function controlCenterTrackingToggleTarget(aUnblock) {
+ return {
+ infoPanelPosition: "rightcenter topleft",
+ query(aDocument) {
+ let popup = aDocument.defaultView.gIdentityHandler._identityPopup;
+ if (popup.state != "open") {
+ return null;
+ }
+ let buttonId = null;
+ if (aUnblock) {
+ if (PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView)) {
+ buttonId = "tracking-action-unblock-private";
+ } else {
+ buttonId = "tracking-action-unblock";
+ }
+ } else {
+ buttonId = "tracking-action-block";
+ }
+ let element = aDocument.getElementById(buttonId);
+ return UITour.isElementVisible(element) ? element : null;
+ },
+ };
+}
+
+this.UITour.init();
+
+/**
+ * UITour Health Report
+ */
+/**
+ * Public API to be called by the UITour code
+ */
+const UITourHealthReport = {
+ recordTreatmentTag: function(tag, value) {
+ return TelemetryController.submitExternalPing("uitour-tag",
+ {
+ version: 1,
+ tagName: tag,
+ tagValue: value,
+ },
+ {
+ addClientId: true,
+ addEnvironment: true,
+ });
+ }
+};
diff --git a/browser/components/uitour/content-UITour.js b/browser/components/uitour/content-UITour.js
new file mode 100644
index 000000000..c33d687e8
--- /dev/null
+++ b/browser/components/uitour/content-UITour.js
@@ -0,0 +1,103 @@
+/* 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/. */
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+const PREF_TEST_WHITELIST = "browser.uitour.testingOrigins";
+const UITOUR_PERMISSION = "uitour";
+
+var UITourListener = {
+ handleEvent: function (event) {
+ if (!Services.prefs.getBoolPref("browser.uitour.enabled")) {
+ return;
+ }
+ if (!this.ensureTrustedOrigin()) {
+ return;
+ }
+ addMessageListener("UITour:SendPageCallback", this);
+ addMessageListener("UITour:SendPageNotification", this);
+ sendAsyncMessage("UITour:onPageEvent", {
+ detail: event.detail,
+ type: event.type,
+ pageVisibilityState: content.document.visibilityState,
+ });
+ },
+
+ isTestingOrigin: function(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, null, null);
+ if (aURI.prePath == testingURI.prePath) {
+ return true;
+ }
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ return false;
+ },
+
+ // This function is copied from UITour.jsm.
+ isSafeScheme: function(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: function() {
+ if (content.top != content)
+ return false;
+
+ let uri = content.document.documentURIObject;
+
+ if (uri.schemeIs("chrome"))
+ return true;
+
+ if (!this.isSafeScheme(uri))
+ return false;
+
+ let permission = Services.perms.testPermission(uri, UITOUR_PERMISSION);
+ if (permission == Services.perms.ALLOW_ACTION)
+ return true;
+
+ return this.isTestingOrigin(uri);
+ },
+
+ receiveMessage: function(aMessage) {
+ switch (aMessage.name) {
+ case "UITour:SendPageCallback":
+ this.sendPageEvent("Response", aMessage.data);
+ break;
+ case "UITour:SendPageNotification":
+ this.sendPageEvent("Notification", aMessage.data);
+ break;
+ }
+ },
+
+ sendPageEvent: function (type, detail) {
+ if (!this.ensureTrustedOrigin()) {
+ return;
+ }
+
+ let doc = content.document;
+ let eventName = "mozUITour" + type;
+ let event = new doc.defaultView.CustomEvent(eventName, {
+ bubbles: true,
+ detail: Cu.cloneInto(detail, doc.defaultView)
+ });
+ doc.dispatchEvent(event);
+ }
+};
+
+addEventListener("mozUITour", UITourListener, false, true);
diff --git a/browser/components/uitour/jar.mn b/browser/components/uitour/jar.mn
new file mode 100644
index 000000000..966a69c96
--- /dev/null
+++ b/browser/components/uitour/jar.mn
@@ -0,0 +1,6 @@
+# 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/.
+
+browser.jar:
+ content/browser/content-UITour.js
diff --git a/browser/components/uitour/moz.build b/browser/components/uitour/moz.build
new file mode 100644
index 000000000..51e6037cc
--- /dev/null
+++ b/browser/components/uitour/moz.build
@@ -0,0 +1,16 @@
+# 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.jsm',
+]
+
+JAR_MANIFESTS += ['jar.mn']
+
+BROWSER_CHROME_MANIFESTS += [
+ 'test/browser.ini',
+]
+
+with Files('**'):
+ BUG_COMPONENT = ('Firefox', 'Tours')
diff --git a/browser/components/uitour/test/.eslintrc.js b/browser/components/uitour/test/.eslintrc.js
new file mode 100644
index 000000000..c764b133d
--- /dev/null
+++ b/browser/components/uitour/test/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../testing/mochitest/browser.eslintrc.js"
+ ]
+};
diff --git a/browser/components/uitour/test/browser.ini b/browser/components/uitour/test/browser.ini
new file mode 100644
index 000000000..ae027a738
--- /dev/null
+++ b/browser/components/uitour/test/browser.ini
@@ -0,0 +1,49 @@
+[DEFAULT]
+support-files =
+ head.js
+ image.png
+ uitour.html
+ ../UITour-lib.js
+
+[browser_backgroundTab.js]
+[browser_closeTab.js]
+[browser_fxa.js]
+skip-if = debug || asan # updateAppMenuItem leaks
+[browser_no_tabs.js]
+[browser_openPreferences.js]
+[browser_openSearchPanel.js]
+skip-if = true # Bug 1113038 - Intermittent "Popup was opened"
+[browser_trackingProtection.js]
+skip-if = os == "linux" # Intermittent NS_ERROR_NOT_AVAILABLE [nsIUrlClassifierDBService.beginUpdate]
+tag = trackingprotection
+support-files =
+ !/browser/base/content/test/general/benignPage.html
+ !/browser/base/content/test/general/trackingPage.html
+[browser_trackingProtection_tour.js]
+tag = trackingprotection
+[browser_showMenu_controlCenter.js]
+tag = trackingprotection
+[browser_UITour.js]
+skip-if = os == "linux" # Intermittent failures, bug 951965
+[browser_UITour2.js]
+[browser_UITour3.js]
+skip-if = os == "linux" # Linux: Bug 986760, Bug 989101.
+[browser_UITour_availableTargets.js]
+[browser_UITour_annotation_size_attributes.js]
+[browser_UITour_defaultBrowser.js]
+[browser_UITour_detach_tab.js]
+[browser_UITour_forceReaderMode.js]
+[browser_UITour_heartbeat.js]
+skip-if = os == "win" # Bug 1277107
+[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 # Disabled due to frequent failures, bugs 1026310 and 1032137
+[browser_UITour_pocket.js]
+skip-if = true # Disabled pending removal of pocket UI Tour
+[browser_UITour_registerPageID.js]
+[browser_UITour_resetProfile.js]
+[browser_UITour_showNewTab.js]
+[browser_UITour_sync.js]
+[browser_UITour_toggleReaderMode.js]
diff --git a/browser/components/uitour/test/browser_UITour.js b/browser/components/uitour/test/browser_UITour.js
new file mode 100644
index 000000000..964be0215
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour.js
@@ -0,0 +1,408 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+Components.utils.import("resource://testing-common/TelemetryArchiveTesting.jsm", this);
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ function test_untrusted_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 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");
+ 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");
+ 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_circle(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 maxDimension = Math.round(Math.max(anchorRect.width, anchorRect.height));
+ let highlightRect = highlight.getBoundingClientRect();
+ info("highlight: width: " + highlightRect.width + " height: " + highlightRect.height);
+ is(Math.round(highlightRect.width), maxDimension, "The width of the highlight should be equal to the largest dimension of the target");
+ is(Math.round(highlightRect.height), maxDimension, "The height of the highlight should be equal to the largest dimension of the target");
+ is(Math.round(highlightRect.height), Math.round(highlightRect.width), "The height and width of the highlight should be the same to create a circle");
+ is(highlight.style.borderRadius, "100%", "The border-radius should be 100% to create a circle");
+ done();
+ }
+ let highlight = document.getElementById("UITourHighlight");
+ is_element_hidden(highlight, "Highlight should initially be hidden");
+
+ gContentAPI.showHighlight("addons");
+ waitForElementToBeVisible(highlight, check_highlight_size, "Highlight should be shown after showHighlight()");
+ },
+ function test_highlight_customize_auto_open_close(done) {
+ let highlight = document.getElementById("UITourHighlight");
+ gContentAPI.showHighlight("customize");
+ 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");
+ 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_customize_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("customize");
+
+ 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");
+ }).then(null, Components.utils.reportError);
+ },
+ 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 onAnimationStart(aEvent) {
+ highlight.removeEventListener("animationstart", onAnimationStart);
+ ok(true, "Animation occurred again even though the effect was the same");
+ checkRandomEffect();
+ });
+ 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 onPopupShown() {
+ popup.removeEventListener("popupshown", onPopupShown);
+ is(popup.popupBoxObject.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 onPopupHidden() {
+ popup.removeEventListener("popuphidden", onPopupHidden);
+
+ popup.addEventListener("popupshown", function onPopupShown() {
+ popup.removeEventListener("popupshown", onPopupShown);
+ done();
+ });
+
+ gContentAPI.showInfo("urlbar", "test title", "test text");
+
+ });
+ gContentAPI.hideInfo();
+ });
+
+ gContentAPI.showInfo("urlbar", "test title", "test text");
+ },
+ taskify(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");
+
+ yield showInfoPromise("urlbar", "urlbar title", "urlbar text");
+
+ is(popup.popupBoxObject.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");
+
+ yield showInfoPromise("search", "search title", "search text");
+
+ is(popup.popupBoxObject.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");
+ }),
+ function test_getConfigurationVersion(done) {
+ function callback(result) {
+ let props = ["defaultUpdateChannel", "version"];
+ for (let property of props) {
+ ok(typeof(result[property]) !== "undefined", "Check " + property + " isn't undefined.");
+ is(result[property], Services.appinfo[property], "Should have the same " + property + " property.");
+ }
+ done();
+ }
+
+ gContentAPI.getConfiguration("appinfo", callback);
+ },
+ function test_getConfigurationDistribution(done) {
+ gContentAPI.getConfiguration("appinfo", (result) => {
+ ok(typeof(result.distribution) !== "undefined", "Check distribution isn't undefined.");
+ is(result.distribution, "default", "Should be \"default\" without preference set.");
+
+ let defaults = Services.prefs.getDefaultBranch("distribution.");
+ let testDistributionID = "TestDistribution";
+ defaults.setCharPref("id", testDistributionID);
+ gContentAPI.getConfiguration("appinfo", (result) => {
+ ok(typeof(result.distribution) !== "undefined", "Check distribution isn't undefined.");
+ is(result.distribution, testDistributionID, "Should have the distribution as set in preference.");
+
+ 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.indexOf("forget") != -1);
+ ok(!available, "Forget button should not be available by default");
+
+ gContentAPI.addNavBarWidget("forget", () => {
+ info("addNavBarWidget callback successfully called");
+
+ let placement = CustomizableUI.getPlacementOfWidget("panic-button");
+ is(placement.area, CustomizableUI.AREA_NAVBAR);
+
+ gContentAPI.getConfiguration("availableTargets", (data) => {
+ let available = (data.targets.indexOf("forget") != -1);
+ ok(available, "Forget button should now be available");
+
+ // Cleanup
+ CustomizableUI.removeWidgetFromArea("panic-button");
+ done();
+ });
+ });
+ });
+ },
+ function test_search(done) {
+ Services.search.init(rv => {
+ if (!Components.isSuccessCode(rv)) {
+ ok(false, "search service init failed: " + rv);
+ done();
+ return;
+ }
+ let defaultEngine = Services.search.defaultEngine;
+ gContentAPI.getConfiguration("search", data => {
+ let visibleEngines = Services.search.getVisibleEngines();
+ let expectedEngines = visibleEngines.filter((engine) => engine.identifier)
+ .map((engine) => "searchEngine-" + engine.identifier);
+
+ 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-/, "");
+
+ let observe = function (subject, topic, verb) {
+ info("browser-search-engine-modified: " + verb);
+ if (verb == "engine-current") {
+ is(Services.search.defaultEngine.identifier, someOtherEngineID, "correct engine was switched to");
+ done();
+ }
+ };
+ Services.obs.addObserver(observe, "browser-search-engine-modified", false);
+ registerCleanupFunction(() => {
+ // Clean up
+ Services.obs.removeObserver(observe, "browser-search-engine-modified");
+ Services.search.defaultEngine = defaultEngine;
+ });
+
+ gContentAPI.setDefaultSearchEngine(someOtherEngineID);
+ });
+ });
+ },
+ taskify(function* test_treatment_tag() {
+ let ac = new TelemetryArchiveTesting.Checker();
+ yield ac.promiseInit();
+ yield gContentAPI.setTreatmentTag("foobar", "baz");
+ // Wait until the treatment telemetry is sent before looking in the archive.
+ yield BrowserTestUtils.waitForContentEvent(gTestTab.linkedBrowser, "mozUITourNotification", false,
+ event => event.detail.event === "TreatmentTag:TelemetrySent");
+ yield 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(function* cleanupMenus() {
+ let shownPromise = promisePanelShown(window);
+ gContentAPI.showMenu("appMenu");
+ yield shownPromise;
+ }),
+];
diff --git a/browser/components/uitour/test/browser_UITour2.js b/browser/components/uitour/test/browser_UITour2.js
new file mode 100644
index 000000000..e74a71afa
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour2.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ function test_info_customize_auto_open_close(done) {
+ let popup = document.getElementById("UITourTooltip");
+ gContentAPI.showInfo("customize", "Customization", "Customize me please!");
+ UITour.getTarget(window, "customize").then((customizeTarget) => {
+ waitForPopupAtAnchor(popup, customizeTarget.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 customize button");
+ });
+ },
+ function test_info_customize_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("customize", "Customization", "Customize me please!");
+
+ UITour.getTarget(window, "customize").then((customizeTarget) => {
+ waitForPopupAtAnchor(popup, customizeTarget.node, function checkMenuIsStillOpen() {
+ 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 checkMenuIsStillOpen() {
+ 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");
+ });
+ }).then(null, Components.utils.reportError);
+ },
+ taskify(function* test_bookmarks_menu() {
+ let bookmarksMenuButton = document.getElementById("bookmarks-menu-button");
+
+ is(bookmarksMenuButton.open, false, "Menu should initially be closed");
+ gContentAPI.showMenu("bookmarks");
+
+ yield waitForConditionPromise(() => {
+ return bookmarksMenuButton.open;
+ }, "Menu should be visible after showMenu()");
+
+ gContentAPI.hideMenu("bookmarks");
+ yield 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 000000000..b852339f1
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour3.js
@@ -0,0 +1,181 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+requestLongerTimeout(2);
+
+add_task(setup_UITourTest);
+
+add_UITour_task(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");
+
+ yield 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(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");
+
+ yield 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.childNodes[0].nodeName, "label", "Text label should be a <label>");
+ is(buttons.childNodes[0].getAttribute("value"), "Regular text", "Text label should have correct value");
+ is(buttons.childNodes[0].getAttribute("image"), "", "Text should have no image");
+ is(buttons.childNodes[0].className, "", "Text should have no class");
+
+ is(buttons.childNodes[1].nodeName, "button", "Link should be a <button>");
+ is(buttons.childNodes[1].getAttribute("label"), "Link", "Link should have correct label");
+ is(buttons.childNodes[1].getAttribute("image"), "", "Link should have no image");
+ is(buttons.childNodes[1].className, "button-link", "Check link class");
+
+ is(buttons.childNodes[2].nodeName, "button", "Button 1 should be a <button>");
+ is(buttons.childNodes[2].getAttribute("label"), "Button 1", "First button should have correct label");
+ is(buttons.childNodes[2].getAttribute("image"), "", "First button should have no image");
+ is(buttons.childNodes[2].className, "", "Button 1 should have no class");
+
+ is(buttons.childNodes[3].nodeName, "button", "Button 2 should be a <button>");
+ is(buttons.childNodes[3].getAttribute("label"), "Button 2", "Second button should have correct label");
+ is(buttons.childNodes[3].getAttribute("image"), imageURL, "Second button should have correct image");
+ is(buttons.childNodes[3].className, "button-primary", "Check button 2 class");
+
+ let promiseHidden = promisePanelElementHidden(window, popup);
+ EventUtils.synthesizeMouseAtCenter(buttons.childNodes[2], {}, window);
+ yield promiseHidden;
+
+ ok(true, "Popup should close automatically");
+
+ let returnValue = yield waitForCallbackResultPromise();
+ is(returnValue.result, "button1", "Correct callback should have been called");
+});
+
+add_UITour_task(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");
+
+ yield 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.childNodes[1].getAttribute("label"), "Link", "Link should have correct label");
+ is(buttons.childNodes[1].getAttribute("image"), "", "Link should have no image");
+ ok(buttons.childNodes[1].classList.contains("button-link"), "Link should have button-link class");
+
+ is(buttons.childNodes[2].getAttribute("label"), "Button 1", "First button should have correct label");
+ is(buttons.childNodes[2].getAttribute("image"), "", "First button should have no image");
+
+ is(buttons.childNodes[3].getAttribute("label"), "Button 2", "Second button should have correct label");
+ is(buttons.childNodes[3].getAttribute("image"), imageURL, "Second button should have correct image");
+
+ let promiseHidden = promisePanelElementHidden(window, popup);
+ EventUtils.synthesizeMouseAtCenter(buttons.childNodes[3], {}, window);
+ yield promiseHidden;
+
+ ok(true, "Popup should close automatically");
+
+ let returnValue = yield waitForCallbackResultPromise();
+
+ is(returnValue.result, "button2", "Correct callback should have been called");
+}),
+
+add_UITour_task(function* test_info_close_button() {
+ let closeButton = document.getElementById("UITourTooltipClose");
+
+ yield showInfoPromise("urlbar", "Close me", "X marks the spot", null, null, "makeInfoOptions");
+
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, window);
+
+ let returnValue = yield waitForCallbackResultPromise();
+
+ is(returnValue.result, "closeButton", "Close button callback called");
+}),
+
+add_UITour_task(function* test_info_target_callback() {
+ let popup = document.getElementById("UITourTooltip");
+
+ yield showInfoPromise("appMenu", "I want to know when the target is clicked", "*click*", null, null, "makeInfoOptions");
+
+ yield PanelUI.show();
+
+ let returnValue = yield 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.
+ yield hideInfoPromise();
+
+ popup.removeAttribute("animate");
+}),
+
+add_UITour_task(function* test_getConfiguration_selectedSearchEngine() {
+ yield new Promise((resolve) => {
+ Services.search.init(Task.async(function*(rv) {
+ ok(Components.isSuccessCode(rv), "Search service initialized");
+ let engine = Services.search.defaultEngine;
+ let data = yield getConfigurationPromise("selectedSearchEngine");
+ is(data.searchEngineIdentifier, engine.identifier, "Correct engine identifier");
+ resolve();
+ }));
+ });
+});
+
+add_UITour_task(function* test_setSearchTerm() {
+ const TERM = "UITour Search Term";
+ yield 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.
+ yield waitForConditionPromise(() => searchbar.value == TERM, "Correct term set");
+});
+
+add_UITour_task(function* test_clearSearchTerm() {
+ yield 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.
+ yield waitForConditionPromise(() => searchbar.value == "", "Search term cleared");
+});
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 000000000..dbdeb9589
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_annotation_size_attributes.js
@@ -0,0 +1,42 @@
+/*
+ * Test that width and height attributes don't get set by widget code on the highlight panel.
+ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+var highlight = document.getElementById("UITourHighlightContainer");
+var tooltip = document.getElementById("UITourTooltip");
+
+add_task(setup_UITourTest);
+
+add_UITour_task(function* test_highlight_size_attributes() {
+ yield gContentAPI.showHighlight("appMenu");
+ yield elementVisiblePromise(highlight,
+ "Highlight should be shown after showHighlight() for the appMenu");
+ yield gContentAPI.showHighlight("urlbar");
+ yield elementVisiblePromise(highlight, "Highlight should be moved to the urlbar");
+ yield new Promise((resolve) => {
+ SimpleTest.executeSoon(() => {
+ is(highlight.height, "", "Highlight panel should have no explicit height set");
+ is(highlight.width, "", "Highlight panel should have no explicit width set");
+ resolve();
+ });
+ });
+});
+
+add_UITour_task(function* test_info_size_attributes() {
+ yield gContentAPI.showInfo("appMenu", "test title", "test text");
+ yield elementVisiblePromise(tooltip, "Tooltip should be shown after showInfo() for the appMenu");
+ yield gContentAPI.showInfo("urlbar", "new title", "new text");
+ yield elementVisiblePromise(tooltip, "Tooltip should be moved to the urlbar");
+ yield new Promise((resolve) => {
+ SimpleTest.executeSoon(() => {
+ is(tooltip.height, "", "Info panel should have no explicit height set");
+ is(tooltip.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 000000000..a6e96e31f
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_availableTargets.js
@@ -0,0 +1,114 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+var hasWebIDE = Services.prefs.getBoolPref("devtools.webide.widget.enabled");
+var hasPocket = Services.prefs.getBoolPref("extensions.pocket.enabled");
+
+requestLongerTimeout(2);
+add_task(setup_UITourTest);
+
+add_UITour_task(function* test_availableTargets() {
+ let data = yield getConfigurationPromise("availableTargets");
+ ok_targets(data, [
+ "accountStatus",
+ "addons",
+ "appMenu",
+ "backForward",
+ "bookmarks",
+ "customize",
+ "help",
+ "home",
+ "devtools",
+ ...(hasPocket ? ["pocket"] : []),
+ "privateWindow",
+ "quit",
+ "readerMode-urlBar",
+ "search",
+ "searchIcon",
+ "trackingProtection",
+ "urlbar",
+ ...(hasWebIDE ? ["webide"] : [])
+ ]);
+
+ ok(UITour.availableTargetsCache.has(window),
+ "Targets should now be cached");
+});
+
+add_UITour_task(function* test_availableTargets_changeWidgets() {
+ CustomizableUI.removeWidgetFromArea("bookmarks-menu-button");
+ ok(!UITour.availableTargetsCache.has(window),
+ "Targets should be evicted from cache after widget change");
+ let data = yield getConfigurationPromise("availableTargets");
+ ok_targets(data, [
+ "accountStatus",
+ "addons",
+ "appMenu",
+ "backForward",
+ "customize",
+ "help",
+ "devtools",
+ "home",
+ ...(hasPocket ? ["pocket"] : []),
+ "privateWindow",
+ "quit",
+ "readerMode-urlBar",
+ "search",
+ "searchIcon",
+ "trackingProtection",
+ "urlbar",
+ ...(hasWebIDE ? ["webide"] : [])
+ ]);
+
+ 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(function* test_availableTargets_exceptionFromGetTarget() {
+ // The query function for the "search" target will throw if it's not found.
+ // Make sure the callback still fires with the other available targets.
+ CustomizableUI.removeWidgetFromArea("search-container");
+ let data = yield getConfigurationPromise("availableTargets");
+ // Default minus "search" and "searchIcon"
+ ok_targets(data, [
+ "accountStatus",
+ "addons",
+ "appMenu",
+ "backForward",
+ "bookmarks",
+ "customize",
+ "help",
+ "home",
+ "devtools",
+ ...(hasPocket ? ["pocket"] : []),
+ "privateWindow",
+ "quit",
+ "readerMode-urlBar",
+ "trackingProtection",
+ "urlbar",
+ ...(hasWebIDE ? ["webide"] : [])
+ ]);
+
+ CustomizableUI.reset();
+});
+
+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. Check for its presence and
+ // insert it into expectedTargets if it's visible.
+ let selectedTabIcon =
+ document.getAnonymousElementByAttribute(gBrowser.selectedTab,
+ "anonid",
+ "tab-icon-image");
+ if (selectedTabIcon && UITour.isElementVisible(selectedTabIcon))
+ expectedTargets.push("selectedTabIcon");
+
+ ok(Array.isArray(actualData.targets), "data.targets should be an array");
+ is(actualData.targets.sort().toString(), expectedTargets.sort().toString(),
+ "Targets should be as expected");
+}
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 000000000..5ebf553b0
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_defaultBrowser.js
@@ -0,0 +1,61 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+var setDefaultBrowserCalled = false;
+
+Cc["@mozilla.org/moz/jssubscript-loader;1"]
+ .getService(Ci.mozIJSSubScriptLoader)
+ .loadSubScript("chrome://mochikit/content/tests/SimpleTest/MockObjects.js", this);
+
+function MockShellService() {}
+MockShellService.prototype = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIShellService]),
+ isDefaultBrowser: function(aStartupCheck, aForAllTypes) { return false; },
+ setDefaultBrowser: function(aClaimAllTypes, aForAllUsers) {
+ setDefaultBrowserCalled = true;
+ },
+ shouldCheckDefaultBrowser: false,
+ canSetDesktopBackground: false,
+ BACKGROUND_TILE : 1,
+ BACKGROUND_STRETCH : 2,
+ BACKGROUND_CENTER : 3,
+ BACKGROUND_FILL : 4,
+ BACKGROUND_FIT : 5,
+ setDesktopBackground: function(aElement, aPosition) {},
+ APPLICATION_MAIL : 0,
+ APPLICATION_NEWS : 1,
+ openApplication: function(aApplication) {},
+ desktopBackgroundColor: 0,
+ openApplicationWithURI: function(aApplication, aURI) {},
+ defaultFeedReader: 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(function* test_isDefaultBrowser() {
+ let shell = Components.classes["@mozilla.org/browser/shell-service;1"]
+ .getService(Components.interfaces.nsIShellService);
+ let isDefault = shell.isDefaultBrowser(false);
+ let data = yield 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 000000000..b8edf6dc4
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_detach_tab.js
@@ -0,0 +1,94 @@
+/* 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 gContentWindow;
+var gContentDoc;
+
+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 visibilitychange events.
+ * In particular this scenario happens for detaching the tab (ie. moving it to a new window).
+ */
+var tests = [
+ taskify(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 windowDestroyedDeferred = Promise.defer();
+ let onDOMWindowDestroyed = (aWindow) => {
+ if (gContentWindow && aWindow == gContentWindow) {
+ Services.obs.removeObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
+ windowDestroyedDeferred.resolve();
+ }
+ };
+
+ let browserStartupDeferred = Promise.defer();
+ Services.obs.addObserver(function onBrowserDelayedStartup(aWindow) {
+ Services.obs.removeObserver(onBrowserDelayedStartup, "browser-delayed-startup-finished");
+ browserStartupDeferred.resolve(aWindow);
+ }, "browser-delayed-startup-finished", false);
+
+ yield ContentTask.spawn(gBrowser.selectedBrowser, myDocIdentifier, myDocIdentifier => {
+ let onVisibilityChange = () => {
+ if (!content.document.hidden) {
+ let win = Cu.waiveXrays(content);
+ win.Mozilla.UITour.showHighlight("appMenu");
+ }
+ };
+ content.document.addEventListener("visibilitychange", onVisibilityChange);
+ content.document.myExpando = myDocIdentifier;
+ });
+ gContentAPI.showHighlight("appMenu");
+
+ yield elementVisiblePromise(highlight);
+
+ gContentWindow = gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ yield browserStartupDeferred.promise;
+
+ // This highlight should be shown thanks to the visibilitychange listener.
+ let newWindowHighlight = gContentWindow.document.getElementById("UITourHighlight");
+ yield elementVisiblePromise(newWindowHighlight);
+
+ let selectedTab = gContentWindow.gBrowser.selectedTab;
+ yield ContentTask.spawn(selectedTab.linkedBrowser, myDocIdentifier, myDocIdentifier => {
+ is(content.document.myExpando, myDocIdentifier, "Document should be selected in new window");
+ });
+ ok(UITour.tourBrowsersByWindow && UITour.tourBrowsersByWindow.has(gContentWindow), "Window should be known");
+ ok(UITour.tourBrowsersByWindow.get(gContentWindow).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 = gContentWindow.gBrowser.selectedTab;
+
+ let shownPromise = promisePanelShown(gContentWindow);
+ gContentAPI.showMenu("appMenu");
+ yield shownPromise;
+
+ isnot(gContentWindow.PanelUI.panel.state, "closed", "Panel should be open");
+ ok(gContentWindow.PanelUI.contents.children.length > 0, "Panel contents should have children");
+ gContentAPI.hideHighlight();
+ gContentAPI.hideMenu("appMenu");
+ gTestTab = null;
+
+ Services.obs.addObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
+ gContentWindow.close();
+
+ yield windowDestroyedDeferred.promise;
+ }),
+];
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 000000000..5b5e883c3
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_forceReaderMode.js
@@ -0,0 +1,17 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(function*() {
+ ok(!gBrowser.selectedBrowser.isArticle, "Should not be an article when we start");
+ ok(document.getElementById("reader-mode-button").hidden, "Button should be hidden.");
+ yield gContentAPI.forceShowReaderIcon();
+ yield 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_heartbeat.js b/browser/components/uitour/test/browser_UITour_heartbeat.js
new file mode 100644
index 000000000..61be1d44b
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_heartbeat.js
@@ -0,0 +1,755 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+function getHeartbeatNotification(aId, aChromeWindow = window) {
+ let notificationBox = aChromeWindow.document.getElementById("high-priority-global-notificationbox");
+ // UITour.jsm prefixes the notification box ID with "heartbeat-" to prevent collisions.
+ return notificationBox.getNotificationWithValue("heartbeat-" + aId);
+}
+
+/**
+ * Simulate a click on a rating element in the Heartbeat notification.
+ *
+ * @param aId
+ * The id of the notification box.
+ * @param aScore
+ * The score related to the rating element we want to click on.
+ */
+function simulateVote(aId, aScore) {
+ let notification = getHeartbeatNotification(aId);
+
+ let ratingContainer = notification.childNodes[0];
+ ok(ratingContainer, "The notification has a valid rating container.");
+
+ let ratingElement = ratingContainer.getElementsByAttribute("data-score", aScore);
+ ok(ratingElement[0], "The rating container contains the requested rating element.");
+
+ ratingElement[0].click();
+}
+
+/**
+ * Simulate a click on the learn-more link.
+ *
+ * @param aId
+ * The id of the notification box.
+ */
+function clickLearnMore(aId) {
+ let notification = getHeartbeatNotification(aId);
+
+ let learnMoreLabel = notification.childNodes[2];
+ ok(learnMoreLabel, "The notification has a valid learn more label.");
+
+ learnMoreLabel.click();
+}
+
+/**
+ * Remove the notification box.
+ *
+ * @param aId
+ * The id of the notification box to remove.
+ * @param [aChromeWindow=window]
+ * The chrome window the notification box is in.
+ */
+function cleanUpNotification(aId, aChromeWindow = window) {
+ let notification = getHeartbeatNotification(aId, aChromeWindow);
+ notification.close();
+}
+
+/**
+ * Check telemetry payload for proper format and expected content.
+ *
+ * @param aPayload
+ * The Telemetry payload to verify
+ * @param aFlowId
+ * Expected value of the flowId field.
+ * @param aExpectedFields
+ * Array of expected fields. No other fields are allowed.
+ */
+function checkTelemetry(aPayload, aFlowId, aExpectedFields) {
+ // Basic payload format
+ is(aPayload.version, 1, "Telemetry ping must have heartbeat version=1");
+ is(aPayload.flowId, aFlowId, "Flow ID in the Telemetry ping must match");
+
+ // Check for superfluous fields
+ let extraKeys = new Set(Object.keys(aPayload));
+ extraKeys.delete("version");
+ extraKeys.delete("flowId");
+
+ // Check for expected fields
+ for (let field of aExpectedFields) {
+ ok(field in aPayload, "The payload should have the field '" + field + "'");
+ if (field.endsWith("TS")) {
+ let ts = aPayload[field];
+ ok(Number.isInteger(ts) && ts > 0, "Timestamp '" + field + "' must be a natural number");
+ }
+ extraKeys.delete(field);
+ }
+
+ is(extraKeys.size, 0, "No unexpected fields in the Telemetry payload");
+}
+
+/**
+ * Waits for an UITour notification dispatched through |UITour.notify|. This should be
+ * done with |gContentAPI.observe|. Unfortunately, in e10s, |gContentAPI.observe| doesn't
+ * allow for multiple calls to the same callback, allowing to catch just the first
+ * notification.
+ *
+ * @param aEventName
+ * The notification name to wait for.
+ * @return {Promise} Resolved with the data that comes with the event.
+ */
+function promiseWaitHeartbeatNotification(aEventName) {
+ return ContentTask.spawn(gTestTab.linkedBrowser, { aEventName },
+ function({ aEventName }) {
+ return new Promise(resolve => {
+ addEventListener("mozUITourNotification", function listener(event) {
+ if (event.detail.event !== aEventName) {
+ return;
+ }
+ removeEventListener("mozUITourNotification", listener, false);
+ resolve(event.detail.params);
+ }, false);
+ });
+ });
+}
+
+/**
+ * Waits for UITour notifications dispatched through |UITour.notify|. This works like
+ * |promiseWaitHeartbeatNotification|, but waits for all the passed notifications to
+ * be received before resolving. If it receives an unaccounted notification, it rejects.
+ *
+ * @param events
+ * An array of expected notification names to wait for.
+ * @return {Promise} Resolved with the data that comes with the event. Rejects with the
+ * name of an undesired notification if received.
+ */
+function promiseWaitExpectedNotifications(events) {
+ return ContentTask.spawn(gTestTab.linkedBrowser, { events },
+ function({ events }) {
+ let stillToReceive = events;
+ return new Promise((res, rej) => {
+ addEventListener("mozUITourNotification", function listener(event) {
+ if (stillToReceive.includes(event.detail.event)) {
+ // Filter out the received event.
+ stillToReceive = stillToReceive.filter(x => x !== event.detail.event);
+ } else {
+ removeEventListener("mozUITourNotification", listener, false);
+ rej(event.detail.event);
+ }
+ // We still need to catch some notifications. Don't do anything.
+ if (stillToReceive.length > 0) {
+ return;
+ }
+ // We don't need to listen for other notifications. Resolve the promise.
+ removeEventListener("mozUITourNotification", listener, false);
+ res();
+ }, false);
+ });
+ });
+}
+
+function validateTimestamp(eventName, timestamp) {
+ info("'" + eventName + "' notification received (timestamp " + timestamp.toString() + ").");
+ ok(Number.isFinite(timestamp), "Timestamp must be a number.");
+}
+
+add_task(function* test_setup() {
+ yield setup_UITourTest();
+ requestLongerTimeout(2);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.uitour.surveyDuration");
+ });
+});
+
+/**
+ * Check that the "stars" heartbeat UI correctly shows and closes.
+ */
+add_UITour_task(function* test_heartbeat_stars_show() {
+ let flowId = "ui-ratefirefox-" + Math.random();
+ let engagementURL = "http://example.com";
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(
+ ["Heartbeat:NotificationOffered", "Heartbeat:NotificationClosed", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL);
+
+ // Validate the returned timestamp.
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Close the heartbeat notification.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+ cleanUpNotification(flowId);
+
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received");
+ checkTelemetry(data, flowId, ["offeredTS", "closedTS"]);
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Check that the heartbeat UI correctly takes optional icon URL.
+ */
+add_UITour_task(function* test_heartbeat_take_optional_icon_URL() {
+ let flowId = "ui-ratefirefox-" + Math.random();
+ let engagementURL = "http://example.com";
+ let iconURL = "chrome://branding/content/icon48.png";
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(
+ ["Heartbeat:NotificationOffered", "Heartbeat:NotificationClosed", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL, null, null, {
+ iconURL: iconURL
+ });
+
+ // Validate the returned timestamp.
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Check the icon URL
+ let notification = getHeartbeatNotification(flowId);
+ is(notification.image, iconURL, "The optional icon URL is not taken correctly");
+
+ // Close the heartbeat notification.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+ cleanUpNotification(flowId);
+
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received");
+ checkTelemetry(data, flowId, ["offeredTS", "closedTS"]);
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Test that the heartbeat UI correctly works with null engagement URL.
+ */
+add_UITour_task(function* test_heartbeat_null_engagementURL() {
+ let flowId = "ui-ratefirefox-" + Math.random();
+ let originalTabCount = gBrowser.tabs.length;
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(["Heartbeat:NotificationOffered",
+ "Heartbeat:NotificationClosed", "Heartbeat:Voted", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, null);
+
+ // Validate the returned timestamp.
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Wait an the Voted, Closed and Telemetry Sent events. They are fired together, so
+ // wait for them here.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let votedPromise = promiseWaitHeartbeatNotification("Heartbeat:Voted");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+
+ // The UI was just shown. We can simulate a click on a rating element (i.e., "star").
+ simulateVote(flowId, 2);
+ data = yield votedPromise;
+ validateTimestamp('Heartbeat:Voted', data.timestamp);
+
+ // Validate the closing timestamp.
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+ is(gBrowser.tabs.length, originalTabCount, "No engagement tab should be opened.");
+
+ // Validate the data we send out.
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received.");
+ checkTelemetry(data, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+ is(data.score, 2, "Checking Telemetry payload.score");
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Test that the heartbeat UI correctly works with an invalid, but non null, engagement URL.
+ */
+add_UITour_task(function* test_heartbeat_invalid_engagement_URL() {
+ let flowId = "ui-ratefirefox-" + Math.random();
+ let originalTabCount = gBrowser.tabs.length;
+ let invalidEngagementURL = "invalidEngagement";
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(["Heartbeat:NotificationOffered",
+ "Heartbeat:NotificationClosed", "Heartbeat:Voted", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, invalidEngagementURL);
+
+ // Validate the returned timestamp.
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Wait an the Voted, Closed and Telemetry Sent events. They are fired together, so
+ // wait for them here.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let votedPromise = promiseWaitHeartbeatNotification("Heartbeat:Voted");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+
+ // The UI was just shown. We can simulate a click on a rating element (i.e., "star").
+ simulateVote(flowId, 2);
+ data = yield votedPromise;
+ validateTimestamp('Heartbeat:Voted', data.timestamp);
+
+ // Validate the closing timestamp.
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+ is(gBrowser.tabs.length, originalTabCount, "No engagement tab should be opened.");
+
+ // Validate the data we send out.
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received.");
+ checkTelemetry(data, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+ is(data.score, 2, "Checking Telemetry payload.score");
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Test that the score is correctly reported.
+ */
+add_UITour_task(function* test_heartbeat_stars_vote() {
+ const expectedScore = 4;
+ let originalTabCount = gBrowser.tabs.length;
+ let flowId = "ui-ratefirefox-" + Math.random();
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(["Heartbeat:NotificationOffered",
+ "Heartbeat:NotificationClosed", "Heartbeat:Voted", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, null);
+
+ // Validate the returned timestamp.
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Wait an the Voted, Closed and Telemetry Sent events. They are fired together, so
+ // wait for them here.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let votedPromise = promiseWaitHeartbeatNotification("Heartbeat:Voted");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+
+ // The UI was just shown. We can simulate a click on a rating element (i.e., "star").
+ simulateVote(flowId, expectedScore);
+ data = yield votedPromise;
+ validateTimestamp('Heartbeat:Voted', data.timestamp);
+ is(data.score, expectedScore, "Should report a score of " + expectedScore);
+
+ // Validate the closing timestamp and vote.
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+ is(gBrowser.tabs.length, originalTabCount, "No engagement tab should be opened.");
+
+ // Validate the data we send out.
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received.");
+ checkTelemetry(data, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+ is(data.score, expectedScore, "Checking Telemetry payload.score");
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Test that the engagement page is correctly opened when voting.
+ */
+add_UITour_task(function* test_heartbeat_engagement_tab() {
+ let engagementURL = "http://example.com";
+ let flowId = "ui-ratefirefox-" + Math.random();
+ let originalTabCount = gBrowser.tabs.length;
+ const expectedTabCount = originalTabCount + 1;
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(["Heartbeat:NotificationOffered",
+ "Heartbeat:NotificationClosed", "Heartbeat:Voted", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL);
+
+ // Validate the returned timestamp.
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Wait an the Voted, Closed and Telemetry Sent events. They are fired together, so
+ // wait for them here.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let votedPromise = promiseWaitHeartbeatNotification("Heartbeat:Voted");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+
+ // The UI was just shown. We can simulate a click on a rating element (i.e., "star").
+ simulateVote(flowId, 1);
+ data = yield votedPromise;
+ validateTimestamp('Heartbeat:Voted', data.timestamp);
+
+ // Validate the closing timestamp, vote and make sure the engagement page was opened.
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+ is(gBrowser.tabs.length, expectedTabCount, "Engagement URL should open in a new tab.");
+ gBrowser.removeCurrentTab();
+
+ // Validate the data we send out.
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received.");
+ checkTelemetry(data, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+ is(data.score, 1, "Checking Telemetry payload.score");
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Test that the engagement button opens the engagement URL.
+ */
+add_UITour_task(function* test_heartbeat_engagement_button() {
+ let engagementURL = "http://example.com";
+ let flowId = "ui-engagewithfirefox-" + Math.random();
+ let originalTabCount = gBrowser.tabs.length;
+ const expectedTabCount = originalTabCount + 1;
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(["Heartbeat:NotificationOffered",
+ "Heartbeat:NotificationClosed", "Heartbeat:Engaged", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("Do you want to engage with us?", "Thank you!", flowId, engagementURL, null, null, {
+ engagementButtonLabel: "Engage Me",
+ });
+
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Wait an the Engaged, Closed and Telemetry Sent events. They are fired together, so
+ // wait for them here.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let engagedPromise = promiseWaitHeartbeatNotification("Heartbeat:Engaged");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+
+ // Simulate user engagement.
+ let notification = getHeartbeatNotification(flowId);
+ is(notification.querySelectorAll(".star-x").length, 0, "No stars should be present");
+ // The UI was just shown. We can simulate a click on the engagement button.
+ let engagementButton = notification.querySelector(".notification-button");
+ is(engagementButton.label, "Engage Me", "Check engagement button text");
+ engagementButton.doCommand();
+
+ data = yield engagedPromise;
+ validateTimestamp('Heartbeat:Engaged', data.timestamp);
+
+ // Validate the closing timestamp, vote and make sure the engagement page was opened.
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+ is(gBrowser.tabs.length, expectedTabCount, "Engagement URL should open in a new tab.");
+ gBrowser.removeCurrentTab();
+
+ // Validate the data we send out.
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received.");
+ checkTelemetry(data, flowId, ["offeredTS", "engagedTS", "closedTS"]);
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Test that the learn more link is displayed and that the page is correctly opened when
+ * clicking on it.
+ */
+add_UITour_task(function* test_heartbeat_learnmore() {
+ let dummyURL = "http://example.com";
+ let flowId = "ui-ratefirefox-" + Math.random();
+ let originalTabCount = gBrowser.tabs.length;
+ const expectedTabCount = originalTabCount + 1;
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(["Heartbeat:NotificationOffered",
+ "Heartbeat:NotificationClosed", "Heartbeat:LearnMore", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, dummyURL,
+ "What is this?", dummyURL);
+
+ let data = yield shownPromise;
+ validateTimestamp('Heartbeat:Offered', data.timestamp);
+
+ // Wait an the LearnMore, Closed and Telemetry Sent events. They are fired together, so
+ // wait for them here.
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let learnMorePromise = promiseWaitHeartbeatNotification("Heartbeat:LearnMore");
+ let pingSentPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+
+ // The UI was just shown. Simulate a click on the learn more link.
+ clickLearnMore(flowId);
+
+ data = yield learnMorePromise;
+ validateTimestamp('Heartbeat:LearnMore', data.timestamp);
+ cleanUpNotification(flowId);
+
+ // The notification was closed.
+ data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+ is(gBrowser.tabs.length, expectedTabCount, "Learn more URL should open in a new tab.");
+ gBrowser.removeCurrentTab();
+
+ // Validate the data we send out.
+ data = yield pingSentPromise;
+ info("'Heartbeat:TelemetrySent' notification received.");
+ checkTelemetry(data, flowId, ["offeredTS", "learnMoreTS", "closedTS"]);
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+add_UITour_task(function* test_invalidEngagementButtonLabel() {
+ let engagementURL = "http://example.com";
+ let flowId = "invalidEngagementButtonLabel-" + Math.random();
+
+ let eventPromise = promisePageEvent();
+
+ gContentAPI.showHeartbeat("Do you want to engage with us?", "Thank you!", flowId, engagementURL,
+ null, null, {
+ engagementButtonLabel: 42,
+ });
+
+ yield eventPromise;
+ ok(!isTourBrowser(gBrowser.selectedBrowser),
+ "Invalid engagementButtonLabel should prevent init");
+
+})
+
+add_UITour_task(function* test_privateWindowsOnly_noneOpen() {
+ let engagementURL = "http://example.com";
+ let flowId = "privateWindowsOnly_noneOpen-" + Math.random();
+
+ let eventPromise = promisePageEvent();
+
+ gContentAPI.showHeartbeat("Do you want to engage with us?", "Thank you!", flowId, engagementURL,
+ null, null, {
+ engagementButtonLabel: "Yes!",
+ privateWindowsOnly: true,
+ });
+
+ yield eventPromise;
+ ok(!isTourBrowser(gBrowser.selectedBrowser),
+ "If there are no private windows opened, tour init should be prevented");
+})
+
+add_UITour_task(function* test_privateWindowsOnly_notMostRecent() {
+ let engagementURL = "http://example.com";
+ let flowId = "notMostRecent-" + Math.random();
+
+ let privateWin = yield BrowserTestUtils.openNewBrowserWindow({ private: true });
+ let mostRecentWin = yield BrowserTestUtils.openNewBrowserWindow();
+
+ let eventPromise = promisePageEvent();
+
+ gContentAPI.showHeartbeat("Do you want to engage with us?", "Thank you!", flowId, engagementURL,
+ null, null, {
+ engagementButtonLabel: "Yes!",
+ privateWindowsOnly: true,
+ });
+
+ yield eventPromise;
+ is(getHeartbeatNotification(flowId, window), null,
+ "Heartbeat shouldn't appear in the default window");
+ is(!!getHeartbeatNotification(flowId, privateWin), true,
+ "Heartbeat should appear in the most recent private window");
+ is(getHeartbeatNotification(flowId, mostRecentWin), null,
+ "Heartbeat shouldn't appear in the most recent non-private window");
+
+ yield BrowserTestUtils.closeWindow(mostRecentWin);
+ yield BrowserTestUtils.closeWindow(privateWin);
+})
+
+add_UITour_task(function* test_privateWindowsOnly() {
+ let engagementURL = "http://example.com";
+ let learnMoreURL = "http://example.org/learnmore/";
+ let flowId = "ui-privateWindowsOnly-" + Math.random();
+
+ let privateWin = yield BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ yield new Promise((resolve) => {
+ gContentAPI.observe(function(aEventName, aData) {
+ info(aEventName + " notification received: " + JSON.stringify(aData, null, 2));
+ ok(false, "No heartbeat notifications should arrive for privateWindowsOnly");
+ }, resolve);
+ });
+
+ gContentAPI.showHeartbeat("Do you want to engage with us?", "Thank you!", flowId, engagementURL,
+ "Learn More", learnMoreURL, {
+ engagementButtonLabel: "Yes!",
+ privateWindowsOnly: true,
+ });
+
+ yield promisePageEvent();
+
+ ok(isTourBrowser(gBrowser.selectedBrowser), "UITour should have been init for the browser");
+
+ let notification = getHeartbeatNotification(flowId, privateWin);
+
+ is(notification.querySelectorAll(".star-x").length, 0, "No stars should be present");
+
+ info("Test the learn more link.");
+ let learnMoreLink = notification.querySelector(".text-link");
+ is(learnMoreLink.value, "Learn More", "Check learn more label");
+ let learnMoreTabPromise = BrowserTestUtils.waitForNewTab(privateWin.gBrowser, null);
+ learnMoreLink.click();
+ let learnMoreTab = yield learnMoreTabPromise;
+ is(learnMoreTab.linkedBrowser.currentURI.host, "example.org", "Check learn more site opened");
+ ok(PrivateBrowsingUtils.isBrowserPrivate(learnMoreTab.linkedBrowser), "Ensure the learn more tab is private");
+ yield BrowserTestUtils.removeTab(learnMoreTab);
+
+ info("Test the engagement button's new tab.");
+ let engagementButton = notification.querySelector(".notification-button");
+ is(engagementButton.label, "Yes!", "Check engagement button text");
+ let engagementTabPromise = BrowserTestUtils.waitForNewTab(privateWin.gBrowser, null);
+ engagementButton.doCommand();
+ let engagementTab = yield engagementTabPromise;
+ is(engagementTab.linkedBrowser.currentURI.host, "example.com", "Check enagement site opened");
+ ok(PrivateBrowsingUtils.isBrowserPrivate(engagementTab.linkedBrowser), "Ensure the engagement tab is private");
+ yield BrowserTestUtils.removeTab(engagementTab);
+
+ yield BrowserTestUtils.closeWindow(privateWin);
+})
+
+/**
+ * Test that the survey closes itself after a while and submits Telemetry
+ */
+add_UITour_task(function* test_telemetry_surveyExpired() {
+ let flowId = "survey-expired-" + Math.random();
+ let engagementURL = "http://example.com";
+ let surveyDuration = 1; // 1 second (pref is in seconds)
+ Services.prefs.setIntPref("browser.uitour.surveyDuration", surveyDuration);
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(["Heartbeat:NotificationOffered",
+ "Heartbeat:NotificationClosed", "Heartbeat:SurveyExpired", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL);
+
+ let expiredPromise = promiseWaitHeartbeatNotification("Heartbeat:SurveyExpired");
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let pingPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+
+ yield Promise.all([shownPromise, expiredPromise, closedPromise]);
+ // Validate the ping data.
+ let data = yield pingPromise;
+ checkTelemetry(data, flowId, ["offeredTS", "expiredTS", "closedTS"]);
+
+ Services.prefs.clearUserPref("browser.uitour.surveyDuration");
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
+
+/**
+ * Check that certain whitelisted experiment parameters get reflected in the
+ * Telemetry ping
+ */
+add_UITour_task(function* test_telemetry_params() {
+ let flowId = "telemetry-params-" + Math.random();
+ let engagementURL = "http://example.com";
+ let extraParams = {
+ "surveyId": "foo",
+ "surveyVersion": 1.5,
+ "testing": true,
+ "notWhitelisted": 123,
+ };
+ let expectedFields = ["surveyId", "surveyVersion", "testing"];
+
+ // We need to call |gContentAPI.observe| at least once to set a valid |notificationListener|
+ // in UITour-lib.js, otherwise no message will get propagated.
+ gContentAPI.observe(() => {});
+
+ let receivedExpectedPromise = promiseWaitExpectedNotifications(
+ ["Heartbeat:NotificationOffered", "Heartbeat:NotificationClosed", "Heartbeat:TelemetrySent"]);
+
+ // Show the Heartbeat notification and wait for it to be displayed.
+ let shownPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationOffered");
+ gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!",
+ flowId, engagementURL, null, null, extraParams);
+ yield shownPromise;
+
+ let closedPromise = promiseWaitHeartbeatNotification("Heartbeat:NotificationClosed");
+ let pingPromise = promiseWaitHeartbeatNotification("Heartbeat:TelemetrySent");
+ cleanUpNotification(flowId);
+
+ // The notification was closed.
+ let data = yield closedPromise;
+ validateTimestamp('Heartbeat:NotificationClosed', data.timestamp);
+
+ // Validate the data we send out.
+ data = yield pingPromise;
+ info("'Heartbeat:TelemetrySent' notification received.");
+ checkTelemetry(data, flowId, ["offeredTS", "closedTS"].concat(expectedFields));
+ for (let param of expectedFields) {
+ is(data[param], extraParams[param],
+ "Whitelisted experiment configs should be copied into Telemetry pings");
+ }
+
+ // This rejects whenever an unexpected notification is received.
+ yield receivedExpectedPromise;
+})
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 000000000..1890739c4
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_modalDialog.js
@@ -0,0 +1,104 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+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 : function (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 : function (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 wm = Cc["@mozilla.org/appshell/window-mediator;1"].
+ getService(Ci.nsIWindowMediator);
+ // var enumerator = wm.getEnumerator("navigator:browser");
+ var enumerator = wm.getXULWindowEnumerator(null);
+
+ while (enumerator.hasMoreElements()) {
+ var win = enumerator.getNext();
+ var windowDocShell = win.QueryInterface(Ci.nsIXULWindow).docShell;
+
+ var containedDocShells = windowDocShell.getDocShellEnumerator(
+ Ci.nsIDocShellTreeItem.typeChrome,
+ Ci.nsIDocShell.ENUMERATE_FORWARDS);
+ while (containedDocShells.hasMoreElements()) {
+ // Get the corresponding document for this docshell
+ var childDocShell = containedDocShells.getNext();
+ // We don't want it if it's not done loading.
+ if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE)
+ continue;
+ var childDoc = childDocShell.QueryInterface(Ci.nsIDocShell)
+ .contentViewer
+ .DOMDocument;
+
+ // ok(true, "Got window: " + childDoc.location.href);
+ if (childDoc.location.href == "chrome://global/content/commonDialog.xul")
+ return childDoc;
+ }
+ }
+
+ return null;
+}
+
+function test() {
+ UITourTest();
+}
+
+
+var tests = [
+ taskify(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"));
+ yield waitForConditionPromise(() => panelShown, "Timed out waiting for panel promise to be assigned", 100);
+ yield panelShown;
+
+ yield 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 000000000..b4b435659
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_observe.js
@@ -0,0 +1,85 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+function test() {
+ requestLongerTimeout(2);
+ UITourTest();
+}
+
+var tests = [
+ function test_no_params(done) {
+ function listener(event, params) {
+ is(event, "test-event-1", "Correct event name");
+ is(params, null, "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");
+ is(params, null, "No param object");
+ gContentAPI.observe(null);
+ gBrowser.removeCurrentTab();
+ done();
+ }
+
+ gContentAPI.observe(listener, () => {
+ gBrowser.selectedTab = gBrowser.addTab("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");
+ is(params, null, "No param object");
+ gContentAPI.observe(null);
+ gBrowser.removeTab(blankTab);
+ done();
+ }
+
+ gContentAPI.observe(listener, () => {
+ blankTab = gBrowser.selectedTab = gBrowser.addTab("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 000000000..cff446573
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_panel_close_annotation.js
@@ -0,0 +1,153 @@
+/* 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 gContentWindow;
+var highlight = document.getElementById("UITourHighlight");
+var tooltip = document.getElementById("UITourTooltip");
+
+function test() {
+ registerCleanupFunction(() => {
+ // Close the find bar in case it's open in the remaining tab
+ gBrowser.getFindBar(gBrowser.selectedTab).close();
+ });
+ UITourTest();
+}
+
+var tests = [
+ function test_highlight_move_outside_panel(done) {
+ gContentAPI.showInfo("urlbar", "test title", "test text");
+ gContentAPI.showHighlight("customize");
+ 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("customize");
+ 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("customize", "customize me!", "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("customize");
+ 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("customize", "customize me!", "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");
+ },
+
+ function test_info_move_outside_panel(done) {
+ gContentAPI.showInfo("addons", "test title", "test text");
+ gContentAPI.showHighlight("urlbar");
+ let addonsButton = document.getElementById("add-ons-button");
+ waitForPopupAtAnchor(tooltip, addonsButton, function checkPanelIsOpen() {
+ isnot(PanelUI.panel.state, "closed", "Panel should have opened");
+
+ // Move the info panel outside which should close the app menu.
+ gContentAPI.showInfo("appMenu", "Cool menu button", "It's three lines");
+ waitForPopupAtAnchor(tooltip, document.getElementById("PanelUI-button"), () => {
+ isnot(PanelUI.panel.state, "open",
+ "Menu should have closed after the highlight moved elsewhere.");
+ is(highlight.parentElement.state, "open", "The highlight should have remained visible");
+ done();
+ }, "Tooltip should move to the appMenu button and still be visible");
+ }, "Tooltip should be shown after showInfo() for a panel item");
+ },
+
+];
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 000000000..29548a475
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_pocket.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+var button;
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ taskify(function* test_menu_show_navbar() {
+ is(button.open, false, "Menu should initially be closed");
+ gContentAPI.showMenu("pocket");
+
+ // The panel gets created dynamically.
+ let widgetPanel = null;
+ yield waitForConditionPromise(() => {
+ widgetPanel = document.getElementById("customizationui-widget-panel");
+ return widgetPanel && widgetPanel.state == "open";
+ }, "Menu should be visible after showMenu()");
+
+ ok(button.open, "Button should know its view is open");
+ ok(!widgetPanel.hasAttribute("noautohide"), "@noautohide shouldn't be on the pocket panel");
+ ok(button.hasAttribute("open"), "Pocket button should know that the menu is open");
+
+ widgetPanel.hidePopup();
+ checkPanelIsHidden(widgetPanel);
+ }),
+ taskify(function* test_menu_show_appMenu() {
+ CustomizableUI.addWidgetToArea("pocket-button", CustomizableUI.AREA_PANEL);
+
+ is(PanelUI.multiView.hasAttribute("panelopen"), false, "Multiview should initially be closed");
+ gContentAPI.showMenu("pocket");
+
+ yield waitForConditionPromise(() => {
+ return PanelUI.panel.state == "open";
+ }, "Menu should be visible after showMenu()");
+
+ ok(!PanelUI.panel.hasAttribute("noautohide"), "@noautohide shouldn't be on the pocket panel");
+ ok(PanelUI.multiView.showingSubView, "Subview should be open");
+ ok(PanelUI.multiView.hasAttribute("panelopen"), "Multiview should know it's open");
+
+ PanelUI.showMainView();
+ PanelUI.panel.hidePopup();
+ checkPanelIsHidden(PanelUI.panel);
+ }),
+];
+
+// End tests
+
+function checkPanelIsHidden(aPanel) {
+ if (aPanel.parentElement) {
+ is_hidden(aPanel);
+ } else {
+ ok(!aPanel.parentElement, "Widget panel should have been removed");
+ }
+ is(button.hasAttribute("open"), false, "Pocket button should know that the panel is closed");
+}
+
+if (Services.prefs.getBoolPref("extensions.pocket.enabled")) {
+ let placement = CustomizableUI.getPlacementOfWidget("pocket-button");
+
+ // Add the button to the nav-bar by default.
+ if (!placement || placement.area != CustomizableUI.AREA_NAVBAR) {
+ CustomizableUI.addWidgetToArea("pocket-button", CustomizableUI.AREA_NAVBAR);
+ }
+ registerCleanupFunction(() => {
+ CustomizableUI.reset();
+ });
+
+ let widgetGroupWrapper = CustomizableUI.getWidget("pocket-button");
+ button = widgetGroupWrapper.forWindow(window).node;
+ ok(button, "Got button node");
+} else {
+ todo(false, "Pocket is disabled so skip its UITour tests");
+ tests = [];
+}
diff --git a/browser/components/uitour/test/browser_UITour_registerPageID.js b/browser/components/uitour/test/browser_UITour_registerPageID.js
new file mode 100644
index 000000000..369abb1ed
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_registerPageID.js
@@ -0,0 +1,108 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+Components.utils.import("resource://gre/modules/UITelemetry.jsm");
+Components.utils.import("resource:///modules/BrowserUITelemetry.jsm");
+
+add_task(function* setup_telemetry() {
+ UITelemetry._enabled = true;
+
+ registerCleanupFunction(function() {
+ Services.prefs.clearUserPref("browser.uitour.seenPageIDs");
+ resetSeenPageIDsLazyGetter();
+ UITelemetry._enabled = undefined;
+ BrowserUITelemetry.setBucket(null);
+ delete window.UITelemetry;
+ delete window.BrowserUITelemetry;
+ });
+});
+
+add_task(setup_UITourTest);
+
+function resetSeenPageIDsLazyGetter() {
+ delete UITour.seenPageIDs;
+ // This should be kept in sync with how UITour.init() sets this.
+ Object.defineProperty(UITour, "seenPageIDs", {
+ get: UITour.restoreSeenPageIDs.bind(UITour),
+ configurable: true,
+ });
+}
+
+function checkExpectedSeenPageIDs(expected) {
+ is(UITour.seenPageIDs.size, expected.length, "Should be " + expected.length + " total seen page IDs");
+
+ for (let id of expected)
+ ok(UITour.seenPageIDs.has(id), "Should have seen '" + id + "' page ID");
+
+ let prefData = Services.prefs.getCharPref("browser.uitour.seenPageIDs");
+ prefData = new Map(JSON.parse(prefData));
+
+ is(prefData.size, expected.length, "Should be " + expected.length + " total seen page IDs persisted");
+
+ for (let id of expected)
+ ok(prefData.has(id), "Should have seen '" + id + "' page ID persisted");
+}
+
+
+add_UITour_task(function test_seenPageIDs_restore() {
+ info("Setting up seenPageIDs to be restored from pref");
+ let data = JSON.stringify([
+ ["savedID1", { lastSeen: Date.now() }],
+ ["savedID2", { lastSeen: Date.now() }],
+ // 9 weeks ago, should auto expire.
+ ["savedID3", { lastSeen: Date.now() - 9 * 7 * 24 * 60 * 60 * 1000 }],
+ ]);
+ Services.prefs.setCharPref("browser.uitour.seenPageIDs",
+ data);
+
+ resetSeenPageIDsLazyGetter();
+ checkExpectedSeenPageIDs(["savedID1", "savedID2"]);
+});
+
+add_UITour_task(function* test_seenPageIDs_set_1() {
+ yield gContentAPI.registerPageID("testpage1");
+
+ yield waitForConditionPromise(() => UITour.seenPageIDs.size == 3, "Waiting for page to be registered.");
+
+ checkExpectedSeenPageIDs(["savedID1", "savedID2", "testpage1"]);
+
+ const PREFIX = BrowserUITelemetry.BUCKET_PREFIX;
+ const SEP = BrowserUITelemetry.BUCKET_SEPARATOR;
+
+ let bucket = PREFIX + "UITour" + SEP + "testpage1";
+ is(BrowserUITelemetry.currentBucket, bucket, "Bucket should have correct name");
+
+ gBrowser.selectedTab = gBrowser.addTab("about:blank");
+ bucket = PREFIX + "UITour" + SEP + "testpage1" + SEP + "inactive" + SEP + "1m";
+ is(BrowserUITelemetry.currentBucket, bucket,
+ "After switching tabs, bucket should be expiring");
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = gTestTab;
+ BrowserUITelemetry.setBucket(null);
+});
+
+add_UITour_task(function* test_seenPageIDs_set_2() {
+ yield gContentAPI.registerPageID("testpage2");
+
+ yield waitForConditionPromise(() => UITour.seenPageIDs.size == 4, "Waiting for page to be registered.");
+
+ checkExpectedSeenPageIDs(["savedID1", "savedID2", "testpage1", "testpage2"]);
+
+ const PREFIX = BrowserUITelemetry.BUCKET_PREFIX;
+ const SEP = BrowserUITelemetry.BUCKET_SEPARATOR;
+
+ let bucket = PREFIX + "UITour" + SEP + "testpage2";
+ is(BrowserUITelemetry.currentBucket, bucket, "Bucket should have correct name");
+
+ gBrowser.removeTab(gTestTab);
+ gTestTab = null;
+ bucket = PREFIX + "UITour" + SEP + "testpage2" + SEP + "closed" + SEP + "1m";
+ is(BrowserUITelemetry.currentBucket, bucket,
+ "After closing tab, bucket should be expiring");
+
+ BrowserUITelemetry.setBucket(null);
+});
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 000000000..c91d0a4f2
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_resetProfile.js
@@ -0,0 +1,48 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+add_task(setup_UITourTest);
+
+// Test that a reset profile dialog appears when "resetFirefox" event is triggered
+add_UITour_task(function* test_resetFirefox() {
+ let canReset = yield getConfigurationPromise("canReset");
+ ok(!canReset, "Shouldn't be able to reset from mochitest's temporary profile.");
+ let dialogPromise = new Promise((resolve) => {
+ let winWatcher = Cc["@mozilla.org/embedcomp/window-watcher;1"].
+ getService(Ci.nsIWindowWatcher);
+ winWatcher.registerNotification(function onOpen(subj, topic, data) {
+ if (topic == "domwindowopened" && subj instanceof Ci.nsIDOMWindow) {
+ subj.addEventListener("load", function onLoad() {
+ subj.removeEventListener("load", onLoad);
+ if (subj.document.documentURI ==
+ "chrome://global/content/resetProfile.xul") {
+ winWatcher.unregisterNotification(onOpen);
+ ok(true, "Observed search manager window open");
+ is(subj.opener, window,
+ "Reset Firefox event opened a reset profile window.");
+ subj.close();
+ resolve();
+ }
+ });
+ }
+ });
+ });
+
+ // 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 = yield getConfigurationPromise("canReset");
+ ok(canReset, "Should be able to reset from mochitest's temporary profile once it's in the profile manager.");
+ yield gContentAPI.resetFirefox();
+ yield dialogPromise;
+ tempProfile.remove(false);
+ canReset = yield 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 000000000..2deb08148
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_showNewTab.js
@@ -0,0 +1,17 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+add_task(setup_UITourTest);
+
+// Test that we can switch to about:newtab
+add_UITour_task(function* test_aboutNewTab() {
+ let newTabLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, "about:newtab");
+ info("Showing about:newtab");
+ yield gContentAPI.showNewTab();
+ info("Waiting for about:newtab to load");
+ yield newTabLoaded;
+ is(gBrowser.selectedBrowser.currentURI.spec, "about:newtab", "Loaded about:newtab");
+});
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 000000000..14ac0c1f6
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_sync.js
@@ -0,0 +1,105 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+registerCleanupFunction(function() {
+ Services.prefs.clearUserPref("services.sync.username");
+});
+
+add_task(setup_UITourTest);
+
+add_UITour_task(function* test_checkSyncSetup_disabled() {
+ let result = yield getConfigurationPromise("sync");
+ is(result.setup, false, "Sync shouldn't be setup by default");
+});
+
+add_UITour_task(function* test_checkSyncSetup_enabled() {
+ Services.prefs.setCharPref("services.sync.username", "uitour@tests.mozilla.org");
+ let result = yield getConfigurationPromise("sync");
+ is(result.setup, true, "Sync should be setup");
+});
+
+add_UITour_task(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 = yield 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 = yield 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 = yield 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 = yield 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(function* test_firefoxAccountsNoParams() {
+ yield gContentAPI.showFirefoxAccounts();
+ yield BrowserTestUtils.browserLoaded(gTestTab.linkedBrowser, false,
+ "about:accounts?action=signup&entrypoint=uitour");
+});
+
+add_UITour_task(function* test_firefoxAccountsValidParams() {
+ yield gContentAPI.showFirefoxAccounts({ utm_foo: "foo", utm_bar: "bar" });
+ yield BrowserTestUtils.browserLoaded(gTestTab.linkedBrowser, false,
+ "about:accounts?action=signup&entrypoint=uitour&utm_foo=foo&utm_bar=bar");
+});
+
+add_UITour_task(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, "+");
+ yield gContentAPI.showFirefoxAccounts({ utm_foo: value });
+ yield BrowserTestUtils.browserLoaded(gTestTab.linkedBrowser, false,
+ "about:accounts?action=signup&entrypoint=uitour&utm_foo=" + expected);
+});
+
+// A helper to check the request was ignored due to invalid params.
+function* checkAboutAccountsNotLoaded() {
+ try {
+ yield waitForConditionPromise(() => {
+ return gBrowser.selectedBrowser.currentURI.spec.startsWith("about:accounts");
+ }, "Check if about:accounts opened");
+ ok(false, "No about:accounts tab should have opened");
+ } catch (ex) {
+ ok(true, "No about:accounts tab opened");
+ }
+}
+
+add_UITour_task(function* test_firefoxAccountsNonObject() {
+ // non-string should be rejected.
+ yield gContentAPI.showFirefoxAccounts(99);
+ yield checkAboutAccountsNotLoaded();
+});
+
+add_UITour_task(function* test_firefoxAccountsNonUtmPrefix() {
+ // Any non "utm_" name should should be rejected.
+ yield gContentAPI.showFirefoxAccounts({ utm_foo: "foo", bar: "bar" });
+ yield checkAboutAccountsNotLoaded();
+});
+
+add_UITour_task(function* test_firefoxAccountsNonAlphaName() {
+ // Any "utm_" name which includes non-alpha chars should be rejected.
+ yield gContentAPI.showFirefoxAccounts({ utm_foo: "foo", "utm_bar=": "bar" });
+ yield checkAboutAccountsNotLoaded();
+});
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 000000000..58313e74b
--- /dev/null
+++ b/browser/components/uitour/test/browser_UITour_toggleReaderMode.js
@@ -0,0 +1,16 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(function*() {
+ ok(!gBrowser.selectedBrowser.currentURI.spec.startsWith("about:reader"),
+ "Should not be in reader mode at start of test.");
+ yield gContentAPI.toggleReaderMode();
+ yield 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 000000000..c4117c698
--- /dev/null
+++ b/browser/components/uitour/test/browser_backgroundTab.js
@@ -0,0 +1,46 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+requestLongerTimeout(2);
+add_task(setup_UITourTest);
+
+add_UITour_task(function* test_bg_getConfiguration() {
+ info("getConfiguration is on the allowed list so should work");
+ yield* loadForegroundTab();
+ let data = yield getConfigurationPromise("availableTargets");
+ ok(data, "Got data from getConfiguration");
+ yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_UITour_task(function* test_bg_showInfo() {
+ info("showInfo isn't on the allowed action list so should be denied");
+ yield* loadForegroundTab();
+
+ yield 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"));
+
+ yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+
+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 = ContentTask.spawn(gBrowser.selectedTab.linkedBrowser, null, 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();
+ });
+ });
+ });
+ yield BrowserTestUtils.openNewForegroundTab(gBrowser);
+ yield 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 000000000..2b998347a
--- /dev/null
+++ b/browser/components/uitour/test/browser_closeTab.js
@@ -0,0 +1,18 @@
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(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");
+ yield gContentAPI.closeTab();
+ yield 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 000000000..36ac45a62
--- /dev/null
+++ b/browser/components/uitour/test/browser_fxa.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+ "resource://gre/modules/FxAccounts.jsm");
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+function test() {
+ UITourTest();
+}
+
+registerCleanupFunction(function*() {
+ yield signOut();
+ gFxAccounts.updateAppMenuItem();
+});
+
+var tests = [
+ taskify(function* test_highlight_accountStatus_loggedOut() {
+ let userData = yield fxAccounts.getSignedInUser();
+ is(userData, null, "Not logged in initially");
+ yield showMenuPromise("appMenu");
+ yield showHighlightPromise("accountStatus");
+ let highlight = document.getElementById("UITourHighlightContainer");
+ is(highlight.getAttribute("targetName"), "accountStatus", "Correct highlight target");
+ }),
+
+ taskify(function* test_highlight_accountStatus_loggedIn() {
+ yield setSignedInUser();
+ let userData = yield fxAccounts.getSignedInUser();
+ isnot(userData, null, "Logged in now");
+ gFxAccounts.updateAppMenuItem(); // Causes a leak
+ yield showMenuPromise("appMenu");
+ yield showHighlightPromise("accountStatus");
+ let highlight = document.getElementById("UITourHighlightContainer");
+ is(highlight.popupBoxObject.anchorNode.id, "PanelUI-fxa-avatar", "Anchored on avatar");
+ is(highlight.getAttribute("targetName"), "accountStatus", "Correct highlight target");
+ }),
+];
+
+// Helpers copied from browser_aboutAccounts.js
+// watch out - these will fire observers which if you aren't careful, may
+// interfere with the tests.
+function setSignedInUser(data) {
+ if (!data) {
+ data = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+ }
+ return fxAccounts.setSignedInUser(data);
+}
+
+function signOut() {
+ // we always want a "localOnly" signout here...
+ return fxAccounts.signOut(true);
+}
diff --git a/browser/components/uitour/test/browser_no_tabs.js b/browser/components/uitour/test/browser_no_tabs.js
new file mode 100644
index 000000000..62048b156
--- /dev/null
+++ b/browser/components/uitour/test/browser_no_tabs.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var HiddenFrame = Cu.import("resource:///modules/HiddenFrame.jsm", {}).HiddenFrame;
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/**
+ * Create a frame in the |hiddenDOMWindow| to host a |browser|, then load the URL in the
+ * latter.
+ *
+ * @param aURL
+ * The URL to open in the browser.
+ **/
+function createHiddenBrowser(aURL) {
+ let frame = new HiddenFrame();
+ return new Promise(resolve =>
+ frame.get().then(aFrame => {
+ let doc = aFrame.document;
+ let browser = doc.createElementNS(XUL_NS, "browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("src", aURL);
+
+ doc.documentElement.appendChild(browser);
+ resolve({frame: frame, browser: browser});
+ }));
+}
+
+/**
+ * Remove the browser and the HiddenFrame.
+ *
+ * @param aFrame
+ * The HiddenFrame to dismiss.
+ * @param aBrowser
+ * The browser to dismiss.
+ */
+function destroyHiddenBrowser(aFrame, aBrowser) {
+ // Dispose of the hidden browser.
+ aBrowser.remove();
+
+ // Take care of the frame holding our invisible browser.
+ aFrame.destroy();
+}
+
+/**
+ * Test that UITour works when called when no tabs are available (e.g., when using windowless
+ * browsers).
+ */
+add_task(function* test_windowless_UITour() {
+ // Get the URL for the test page.
+ let pageURL = getRootDirectory(gTestPath) + "uitour.html";
+
+ // Allow the URL to use the UITour.
+ info("Adding UITour permission to the test page.");
+ let pageURI = Services.io.newURI(pageURL, null, null);
+ Services.perms.add(pageURI, "uitour", Services.perms.ALLOW_ACTION);
+
+ // UITour's ping will resolve this promise.
+ let deferredPing = Promise.defer();
+
+ // Create a windowless browser and test that UITour works in it.
+ let browserPromise = createHiddenBrowser(pageURL);
+ browserPromise.then(frameInfo => {
+ isnot(frameInfo.browser, null, "The browser must exist and not be null.");
+
+ // Load UITour frame script.
+ frameInfo.browser.messageManager.loadFrameScript(
+ "chrome://browser/content/content-UITour.js", false);
+
+ // When the page loads, try to use UITour API.
+ frameInfo.browser.addEventListener("load", function loadListener() {
+ info("The test page was correctly loaded.");
+
+ frameInfo.browser.removeEventListener("load", loadListener, true);
+
+ // Get a reference to the UITour API.
+ info("Testing access to the UITour API.");
+ let contentWindow = Cu.waiveXrays(frameInfo.browser.contentDocument.defaultView);
+ isnot(contentWindow, null, "The content window must exist and not be null.");
+
+ let uitourAPI = contentWindow.Mozilla.UITour;
+
+ // Test the UITour API with a ping.
+ uitourAPI.ping(function() {
+ info("Ping response received from the UITour API.");
+
+ // Make sure to clean up.
+ destroyHiddenBrowser(frameInfo.frame, frameInfo.browser);
+
+ // Resolve our promise.
+ deferredPing.resolve();
+ });
+ }, true);
+ });
+
+ // Wait for the UITour ping to complete.
+ yield deferredPing.promise;
+});
diff --git a/browser/components/uitour/test/browser_openPreferences.js b/browser/components/uitour/test/browser_openPreferences.js
new file mode 100644
index 000000000..c41865120
--- /dev/null
+++ b/browser/components/uitour/test/browser_openPreferences.js
@@ -0,0 +1,36 @@
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(function* test_openPreferences() {
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, "about:preferences");
+ yield gContentAPI.openPreferences();
+ let tab = yield promiseTabOpened;
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+add_UITour_task(function* test_openInvalidPreferences() {
+ yield gContentAPI.openPreferences(999);
+
+ try {
+ yield 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(function* test_openPrivacyPreferences() {
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, "about:preferences#privacy");
+ yield gContentAPI.openPreferences("privacy");
+ let tab = yield promiseTabOpened;
+ yield 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 000000000..5faa9db02
--- /dev/null
+++ b/browser/components/uitour/test/browser_openSearchPanel.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+function test() {
+ UITourTest();
+}
+
+var tests = [
+ function test_openSearchPanel(done) {
+ let searchbar = document.getElementById("searchbar");
+
+ // If suggestions are enabled, the panel will attempt to use the network to connect
+ // to the suggestions provider, causing the test suite to fail.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.search.suggest.enabled");
+ });
+
+ 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/browser_showMenu_controlCenter.js b/browser/components/uitour/test/browser_showMenu_controlCenter.js
new file mode 100644
index 000000000..0faa5f862
--- /dev/null
+++ b/browser/components/uitour/test/browser_showMenu_controlCenter.js
@@ -0,0 +1,44 @@
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+const CONTROL_CENTER_PANEL = gIdentityHandler._identityPopup;
+const CONTROL_CENTER_MENU_NAME = "controlCenter";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+add_task(setup_UITourTest);
+
+add_UITour_task(function* test_showMenu() {
+ is_element_hidden(CONTROL_CENTER_PANEL, "Panel should initially be hidden");
+ yield showMenuPromise(CONTROL_CENTER_MENU_NAME);
+ is_element_visible(CONTROL_CENTER_PANEL, "Panel should be visible after showMenu");
+
+ yield gURLBar.focus();
+ is_element_visible(CONTROL_CENTER_PANEL, "Panel should remain visible after focus outside");
+
+ yield showMenuPromise(CONTROL_CENTER_MENU_NAME);
+ is_element_visible(CONTROL_CENTER_PANEL,
+ "Panel should remain visible and callback called after a 2nd showMenu");
+
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: "about:blank"
+ }, function*() {
+ ok(true, "Tab opened");
+ });
+
+ is_element_hidden(CONTROL_CENTER_PANEL, "Panel should hide upon tab switch");
+});
+
+add_UITour_task(function* test_hideMenu() {
+ is_element_hidden(CONTROL_CENTER_PANEL, "Panel should initially be hidden");
+ yield showMenuPromise(CONTROL_CENTER_MENU_NAME);
+ is_element_visible(CONTROL_CENTER_PANEL, "Panel should be visible after showMenu");
+ let hidePromise = promisePanelElementHidden(window, CONTROL_CENTER_PANEL);
+ yield gContentAPI.hideMenu(CONTROL_CENTER_MENU_NAME);
+ yield hidePromise;
+
+ is_element_hidden(CONTROL_CENTER_PANEL, "Panel should hide after hideMenu");
+});
diff --git a/browser/components/uitour/test/browser_trackingProtection.js b/browser/components/uitour/test/browser_trackingProtection.js
new file mode 100644
index 000000000..32a9920ec
--- /dev/null
+++ b/browser/components/uitour/test/browser_trackingProtection.js
@@ -0,0 +1,90 @@
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+const PREF_INTRO_COUNT = "privacy.trackingprotection.introCount";
+const PREF_TP_ENABLED = "privacy.trackingprotection.enabled";
+const BENIGN_PAGE = "http://tracking.example.org/browser/browser/base/content/test/general/benignPage.html";
+const TRACKING_PAGE = "http://tracking.example.org/browser/browser/base/content/test/general/trackingPage.html";
+const TOOLTIP_PANEL = document.getElementById("UITourTooltip");
+const TOOLTIP_ANCHOR = document.getElementById("tracking-protection-icon");
+
+var {UrlClassifierTestUtils} = Cu.import("resource://testing-common/UrlClassifierTestUtils.jsm", {});
+
+registerCleanupFunction(function() {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.prefs.clearUserPref(PREF_TP_ENABLED);
+ Services.prefs.clearUserPref(PREF_INTRO_COUNT);
+});
+
+function allowOneIntro() {
+ Services.prefs.setIntPref(PREF_INTRO_COUNT, TrackingProtection.MAX_INTROS - 1);
+}
+
+add_task(function* setup_test() {
+ Services.prefs.setBoolPref(PREF_TP_ENABLED, true);
+ yield UrlClassifierTestUtils.addTestTrackers();
+});
+
+add_task(function* test_benignPage() {
+ info("Load a test page not containing tracking elements");
+ allowOneIntro();
+ yield BrowserTestUtils.withNewTab({gBrowser, url: BENIGN_PAGE}, function*() {
+ yield waitForConditionPromise(() => {
+ return is_visible(TOOLTIP_PANEL);
+ }, "Info panel shouldn't appear on a benign page").
+ then(() => ok(false, "Info panel shouldn't appear"),
+ () => {
+ ok(true, "Info panel didn't appear on a benign page");
+ });
+
+ });
+});
+
+add_task(function* test_trackingPages() {
+ info("Load a test page containing tracking elements");
+ allowOneIntro();
+ yield BrowserTestUtils.withNewTab({gBrowser, url: TRACKING_PAGE}, function*() {
+ yield new Promise((resolve, reject) => {
+ waitForPopupAtAnchor(TOOLTIP_PANEL, TOOLTIP_ANCHOR, resolve,
+ "Intro panel should appear");
+ });
+
+ is(Services.prefs.getIntPref(PREF_INTRO_COUNT), TrackingProtection.MAX_INTROS, "Check intro count increased");
+
+ let step2URL = Services.urlFormatter.formatURLPref("privacy.trackingprotection.introURL") +
+ "?step=2&newtab=true";
+ let buttons = document.getElementById("UITourTooltipButtons");
+
+ info("Click the step text and nothing should happen");
+ let tabCount = gBrowser.tabs.length;
+ yield EventUtils.synthesizeMouseAtCenter(buttons.children[0], {});
+ is(gBrowser.tabs.length, tabCount, "Same number of tabs should be open");
+
+ info("Resetting count to test that viewing the tour prevents future panels");
+ allowOneIntro();
+
+ let panelHiddenPromise = promisePanelElementHidden(window, TOOLTIP_PANEL);
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, step2URL);
+ info("Clicking the main button");
+ EventUtils.synthesizeMouseAtCenter(buttons.children[1], {});
+ let tab = yield tabPromise;
+ is(Services.prefs.getIntPref(PREF_INTRO_COUNT), TrackingProtection.MAX_INTROS,
+ "Check intro count is at the max after opening step 2");
+ is(gBrowser.tabs.length, tabCount + 1, "Tour step 2 tab opened");
+ yield panelHiddenPromise;
+ ok(true, "Panel hid when the button was clicked");
+ yield BrowserTestUtils.removeTab(tab);
+ });
+
+ info("Open another tracking page and make sure we don't show the panel again");
+ yield BrowserTestUtils.withNewTab({gBrowser, url: TRACKING_PAGE}, function*() {
+ yield waitForConditionPromise(() => {
+ return is_visible(TOOLTIP_PANEL);
+ }, "Info panel shouldn't appear more than MAX_INTROS").
+ then(() => ok(false, "Info panel shouldn't appear again"),
+ () => {
+ ok(true, "Info panel didn't appear more than MAX_INTROS on tracking pages");
+ });
+
+ });
+});
diff --git a/browser/components/uitour/test/browser_trackingProtection_tour.js b/browser/components/uitour/test/browser_trackingProtection_tour.js
new file mode 100644
index 000000000..0ee0e1686
--- /dev/null
+++ b/browser/components/uitour/test/browser_trackingProtection_tour.js
@@ -0,0 +1,77 @@
+"use strict";
+
+var gTestTab;
+var gContentAPI;
+var gContentWindow;
+
+const { UrlClassifierTestUtils } = Cu.import("resource://testing-common/UrlClassifierTestUtils.jsm", {});
+
+const TP_ENABLED_PREF = "privacy.trackingprotection.enabled";
+
+add_task(setup_UITourTest);
+
+add_task(function* test_setup() {
+ Services.prefs.setBoolPref("privacy.trackingprotection.enabled", true);
+ yield UrlClassifierTestUtils.addTestTrackers();
+
+ registerCleanupFunction(function() {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.prefs.clearUserPref("privacy.trackingprotection.enabled");
+ });
+});
+
+add_UITour_task(function* test_unblock_target() {
+ yield* checkToggleTarget("controlCenter-trackingUnblock");
+});
+
+add_UITour_task(function* setup_block_target() {
+ // Preparation for test_block_target. These are separate since the reload
+ // interferes with UITour as it does a teardown. All we really care about
+ // is the permission manager entry but UITour tests shouldn't rely on that
+ // implementation detail.
+ TrackingProtection.disableForCurrentPage();
+});
+
+add_UITour_task(function* test_block_target() {
+ yield* checkToggleTarget("controlCenter-trackingBlock");
+ TrackingProtection.enableForCurrentPage();
+});
+
+
+function* checkToggleTarget(targetID) {
+ let popup = document.getElementById("UITourTooltip");
+
+ yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () {
+ let doc = content.document;
+ let iframe = doc.createElement("iframe");
+ iframe.setAttribute("id", "tracking-element");
+ iframe.setAttribute("src", "https://tracking.example.com/");
+ doc.body.insertBefore(iframe, doc.body.firstChild);
+ });
+
+ let testTargetAvailability = function* (expectedAvailable) {
+ let data = yield getConfigurationPromise("availableTargets");
+ let available = (data.targets.indexOf(targetID) != -1);
+ is(available, expectedAvailable, "Target has expected availability.");
+ };
+ yield testTargetAvailability(false);
+ yield showMenuPromise("controlCenter");
+ yield testTargetAvailability(true);
+
+ yield showInfoPromise(targetID, "This is " + targetID,
+ "My arrow should be on the side");
+ is(popup.popupBoxObject.alignmentPosition, "end_before",
+ "Check " + targetID + " position");
+
+ let hideMenuPromise =
+ promisePanelElementHidden(window, gIdentityHandler._identityPopup);
+ yield gContentAPI.hideMenu("controlCenter");
+ yield hideMenuPromise;
+
+ ok(!is_visible(popup), "The tooltip should now be hidden.");
+ yield testTargetAvailability(false);
+
+ yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () {
+ content.document.getElementById("tracking-element").remove();
+ });
+}
diff --git a/browser/components/uitour/test/head.js b/browser/components/uitour/test/head.js
new file mode 100644
index 000000000..2b5b994ae
--- /dev/null
+++ b/browser/components/uitour/test/head.js
@@ -0,0 +1,449 @@
+"use strict";
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UITour",
+ "resource:///modules/UITour.jsm");
+
+
+const SINGLE_TRY_TIMEOUT = 100;
+const NUMBER_OF_TRIES = 30;
+
+function waitForConditionPromise(condition, timeoutMsg, tryCount=NUMBER_OF_TRIES) {
+ let defer = Promise.defer();
+ let tries = 0;
+ function checkCondition() {
+ if (tries >= tryCount) {
+ defer.reject(timeoutMsg);
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ return defer.reject(e);
+ }
+ if (conditionPassed) {
+ return defer.resolve();
+ }
+ tries++;
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ return undefined;
+ }
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ return defer.promise;
+}
+
+function waitForCondition(condition, nextTest, errorMsg) {
+ waitForConditionPromise(condition, errorMsg).then(nextTest, (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 (done) => {
+ // Output the inner function name otherwise no name will be output.
+ info("\t" + fun.name);
+ return Task.spawn(fun).then(done, (reason) => {
+ ok(false, reason);
+ done();
+ });
+ };
+}
+
+function is_hidden(element) {
+ var style = element.ownerGlobal.getComputedStyle(element);
+ if (style.display == "none")
+ return true;
+ if (style.visibility != "visible")
+ return true;
+ if (style.display == "-moz-popup")
+ return ["hiding", "closed"].indexOf(element.state) != -1;
+
+ // 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) {
+ var style = element.ownerGlobal.getComputedStyle(element);
+ if (style.display == "none")
+ return false;
+ if (style.visibility != "visible")
+ return false;
+ if (style.display == "-moz-popup" && 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, nextTest, msg) {
+ waitForCondition(() => is_visible(element),
+ () => {
+ ok(true, msg);
+ nextTest();
+ },
+ "Timeout waiting for visibility: " + msg);
+}
+
+function waitForElementToBeHidden(element, nextTest, msg) {
+ waitForCondition(() => is_hidden(element),
+ () => {
+ ok(true, msg);
+ nextTest();
+ },
+ "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, nextTest, msg) {
+ waitForCondition(() => is_visible(popup) && popup.popupBoxObject.anchorNode == anchorNode,
+ () => {
+ ok(true, msg);
+ is_element_visible(popup, "Popup should be visible");
+ nextTest();
+ },
+ "Timeout waiting for popup at anchor: " + msg);
+}
+
+function getConfigurationPromise(configName) {
+ return ContentTask.spawn(gTestTab.linkedBrowser, configName, configName => {
+ return new Promise((resolve) => {
+ let contentWin = Components.utils.waiveXrays(content);
+ contentWin.Mozilla.UITour.getConfiguration(configName, resolve);
+ });
+ });
+}
+
+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 ContentTask.spawn(gTestTab.linkedBrowser, [...arguments], args => {
+ let contentWin = Components.utils.waiveXrays(content);
+ let [target, title, text, icon, buttonsFunctionName, optionsFunctionName] = args;
+ let buttons = buttonsFunctionName ? contentWin[buttonsFunctionName]() : null;
+ let options = optionsFunctionName ? contentWin[optionsFunctionName]() : null;
+ contentWin.Mozilla.UITour.showInfo(target, title, text, icon, 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 ContentTask.spawn(gTestTab.linkedBrowser, name, name => {
+ return new Promise((resolve) => {
+ let contentWin = Components.utils.waiveXrays(content);
+ contentWin.Mozilla.UITour.showMenu(name, resolve);
+ });
+ });
+}
+
+function waitForCallbackResultPromise() {
+ return ContentTask.spawn(gTestTab.linkedBrowser, null, function*() {
+ let contentWin = Components.utils.waiveXrays(content);
+ yield 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.jsm 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);
+}
+
+function promisePageEvent() {
+ return new Promise((resolve) => {
+ Services.mm.addMessageListener("UITour:onPageEvent", function onPageEvent(aMessage) {
+ Services.mm.removeMessageListener("UITour:onPageEvent", onPageEvent);
+ SimpleTest.executeSoon(resolve);
+ });
+ });
+}
+
+function loadUITourTestPage(callback, host = "https://example.org/") {
+ if (gTestTab)
+ gBrowser.removeTab(gTestTab);
+
+ let url = getRootDirectory(gTestPath) + "uitour.html";
+ url = url.replace("chrome://mochitests/content/", host);
+
+ gTestTab = gBrowser.addTab(url);
+ gBrowser.selectedTab = gTestTab;
+
+ gTestTab.linkedBrowser.addEventListener("load", function onLoad() {
+ gTestTab.linkedBrowser.removeEventListener("load", onLoad, true);
+
+ if (gMultiProcessBrowser) {
+ // When e10s is enabled, make gContentAPI and gContentWindow proxies which has every property
+ // return a function which calls the method of the same name on
+ // contentWin.Mozilla.UITour/contentWin in a ContentTask.
+ let contentWinHandler = {
+ get(target, prop, receiver) {
+ return (...args) => {
+ let taskArgs = {
+ methodName: prop,
+ args,
+ };
+ return ContentTask.spawn(gTestTab.linkedBrowser, taskArgs, args => {
+ let contentWin = Components.utils.waiveXrays(content);
+ return contentWin[args.methodName].apply(contentWin, args.args);
+ });
+ };
+ },
+ };
+ gContentWindow = new Proxy({}, contentWinHandler);
+
+ let UITourHandler = {
+ get(target, prop, receiver) {
+ return (...args) => {
+ let browser = gTestTab.linkedBrowser;
+ const proxyFunctionName = "UITourHandler:proxiedfunction-";
+ // We need to proxy any callback functions using messages:
+ let callbackMap = new Map();
+ 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") {
+ callbackMap.set(index, arg);
+ fnIndices.push(index);
+ let handler = function(msg) {
+ // Please note that this handler assumes that the callback is used only once.
+ // That means that a single gContentAPI.observer() call can't be used to observe
+ // multiple events.
+ browser.messageManager.removeMessageListener(proxyFunctionName + index, handler);
+ callbackMap.get(index).apply(null, msg.data);
+ };
+ browser.messageManager.addMessageListener(proxyFunctionName + index, handler);
+ return "";
+ }
+ return arg;
+ });
+ let taskArgs = {
+ methodName: prop,
+ args,
+ fnIndices,
+ };
+ return ContentTask.spawn(browser, taskArgs, function*(args) {
+ let contentWin = Components.utils.waiveXrays(content);
+ let callbacksCalled = 0;
+ let resolveCallbackPromise;
+ let allCallbacksCalledPromise = new Promise(resolve => resolveCallbackPromise = resolve);
+ let argumentsWithFunctions = args.args.map((arg, index) => {
+ if (arg === "" && args.fnIndices.includes(index)) {
+ return function() {
+ callbacksCalled++;
+ sendAsyncMessage("UITourHandler:proxiedfunction-" + index, Array.from(arguments));
+ if (callbacksCalled >= args.fnIndices.length) {
+ resolveCallbackPromise();
+ }
+ };
+ }
+ return arg;
+ });
+ let rv = contentWin.Mozilla.UITour[args.methodName].apply(contentWin.Mozilla.UITour,
+ argumentsWithFunctions);
+ if (args.fnIndices.length) {
+ yield allCallbacksCalledPromise;
+ }
+ return rv;
+ });
+ };
+ },
+ };
+ gContentAPI = new Proxy({}, UITourHandler);
+ } else {
+ gContentWindow = Components.utils.waiveXrays(gTestTab.linkedBrowser.contentDocument.defaultView);
+ gContentAPI = gContentWindow.Mozilla.UITour;
+ }
+
+ waitForFocus(callback, gTestTab.linkedBrowser);
+ }, true);
+}
+
+// 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 testHttpsUri = Services.io.newURI("https://example.org", null, null);
+ let testHttpUri = Services.io.newURI("http://example.org", null, null);
+ Services.perms.add(testHttpsUri, "uitour", Services.perms.ALLOW_ACTION);
+ Services.perms.add(testHttpUri, "uitour", Services.perms.ALLOW_ACTION);
+
+ // 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.gContentWindow;
+ delete window.gContentAPI;
+ if (gTestTab)
+ gBrowser.removeTab(gTestTab);
+ delete window.gTestTab;
+ Services.prefs.clearUserPref("browser.uitour.enabled");
+ Services.perms.remove(testHttpsUri, "uitour");
+ Services.perms.remove(testHttpUri, "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;
+
+ 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 == 0) {
+ 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 = function*() {
+ yield new Promise((resolve) => {
+ waitForFocus(function() {
+ loadUITourTestPage(function() {
+ let funcPromise = Task.spawn(func)
+ .then(() => done(true),
+ (reason) => {
+ ok(false, reason);
+ return done(true);
+ });
+ resolve(funcPromise);
+ });
+ });
+ });
+ };
+ Object.defineProperty(genFun, "name", {
+ configurable: true,
+ value: func.name,
+ });
+ add_task(genFun);
+}
diff --git a/browser/components/uitour/test/image.png b/browser/components/uitour/test/image.png
new file mode 100644
index 000000000..597c7fd2c
--- /dev/null
+++ b/browser/components/uitour/test/image.png
Binary files differ
diff --git a/browser/components/uitour/test/uitour.html b/browser/components/uitour/test/uitour.html
new file mode 100644
index 000000000..6c42ac7f8
--- /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>