diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /browser/components/uitour | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-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')
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 Binary files differnew file mode 100644 index 000000000..597c7fd2c --- /dev/null +++ b/browser/components/uitour/test/image.png diff --git a/browser/components/uitour/test/uitour.html b/browser/components/uitour/test/uitour.html new file mode 100644 index 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> |