From 43a97878ce14b72f0981164f87f2e35e14151312 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 11:22:09 +0200 Subject: Adding upstream version 110.0.1. Signed-off-by: Daniel Baumann --- browser/components/firefoxview/colorways-card.mjs | 179 ++++ .../firefoxview/content/callout-colorways-dark.svg | 4 + .../firefoxview/content/callout-colorways.svg | 4 + .../content/callout-tab-pickup-dark.svg | 4 + .../firefoxview/content/callout-tab-pickup.svg | 4 + .../firefoxview/content/cfr-lightning-dark.svg | 6 + .../firefoxview/content/cfr-lightning.svg | 6 + .../firefoxview/content/recently-closed-empty.svg | 12 + .../firefoxview/content/tab-pickup-empty.svg | 18 + .../firefox-view-notification-manager.sys.mjs | 112 +++ .../firefox-view-tabs-setup-manager.sys.mjs | 604 +++++++++++ browser/components/firefoxview/firefoxview.css | 1049 ++++++++++++++++++++ browser/components/firefoxview/firefoxview.html | 176 ++++ browser/components/firefoxview/firefoxview.mjs | 69 ++ browser/components/firefoxview/helpers.mjs | 103 ++ browser/components/firefoxview/jar.mn | 21 + browser/components/firefoxview/moz.build | 16 + .../firefoxview/recently-closed-tabs.mjs | 488 +++++++++ .../firefoxview/tab-pickup-container.mjs | 323 ++++++ browser/components/firefoxview/tab-pickup-list.mjs | 348 +++++++ .../firefoxview/tests/browser/browser.ini | 33 + .../tests/browser/browser_cfr_message.js | 67 ++ .../tests/browser/browser_colorways_card.js | 443 +++++++++ .../browser_dragDrop_after_opening_fxViewTab.js | 120 +++ .../tests/browser/browser_entrypoint_management.js | 67 ++ .../tests/browser/browser_feature_callout.js | 655 ++++++++++++ .../browser/browser_feature_callout_position.js | 403 ++++++++ .../browser/browser_feature_callout_resize.js | 127 +++ .../browser/browser_feature_callout_targeting.js | 175 ++++ .../tests/browser/browser_firefoxview.js | 18 + .../browser/browser_firefoxview_accessibility.js | 110 ++ .../browser_firefoxview_feature_callout_a11y.js | 55 + .../tests/browser/browser_firefoxview_tab.js | 249 +++++ .../tests/browser/browser_keyboard_focus.js | 93 ++ .../browser/browser_media_query_dom_sorting.js | 54 + .../tests/browser/browser_notification_dot.js | 335 +++++++ .../tests/browser/browser_recently_closed_tabs.js | 798 +++++++++++++++ .../browser_recently_closed_tabs_keyboard.js | 255 +++++ .../tests/browser/browser_reload_firefoxview.js | 36 + .../tests/browser/browser_setup_errors.js | 374 +++++++ .../browser/browser_setup_primary_password.js | 150 +++ .../tests/browser/browser_setup_state.js | 769 ++++++++++++++ .../browser/browser_setup_synced_tabs_loading.js | 180 ++++ .../tests/browser/browser_sync_admin_disabled.js | 72 ++ .../tests/browser/browser_tab_close_last_tab.js | 43 + .../tests/browser/browser_tab_on_close_warning.js | 63 ++ .../tests/browser/browser_tab_pickup_list.js | 607 +++++++++++ .../firefoxview/tests/browser/browser_ui_state.js | 145 +++ .../components/firefoxview/tests/browser/head.js | 599 +++++++++++ 49 files changed, 10641 insertions(+) create mode 100644 browser/components/firefoxview/colorways-card.mjs create mode 100644 browser/components/firefoxview/content/callout-colorways-dark.svg create mode 100644 browser/components/firefoxview/content/callout-colorways.svg create mode 100644 browser/components/firefoxview/content/callout-tab-pickup-dark.svg create mode 100644 browser/components/firefoxview/content/callout-tab-pickup.svg create mode 100644 browser/components/firefoxview/content/cfr-lightning-dark.svg create mode 100644 browser/components/firefoxview/content/cfr-lightning.svg create mode 100644 browser/components/firefoxview/content/recently-closed-empty.svg create mode 100644 browser/components/firefoxview/content/tab-pickup-empty.svg create mode 100644 browser/components/firefoxview/firefox-view-notification-manager.sys.mjs create mode 100644 browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs create mode 100644 browser/components/firefoxview/firefoxview.css create mode 100644 browser/components/firefoxview/firefoxview.html create mode 100644 browser/components/firefoxview/firefoxview.mjs create mode 100644 browser/components/firefoxview/helpers.mjs create mode 100644 browser/components/firefoxview/jar.mn create mode 100644 browser/components/firefoxview/moz.build create mode 100644 browser/components/firefoxview/recently-closed-tabs.mjs create mode 100644 browser/components/firefoxview/tab-pickup-container.mjs create mode 100644 browser/components/firefoxview/tab-pickup-list.mjs create mode 100644 browser/components/firefoxview/tests/browser/browser.ini create mode 100644 browser/components/firefoxview/tests/browser/browser_cfr_message.js create mode 100644 browser/components/firefoxview/tests/browser/browser_colorways_card.js create mode 100644 browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_entrypoint_management.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_position.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_keyboard_focus.js create mode 100644 browser/components/firefoxview/tests/browser/browser_media_query_dom_sorting.js create mode 100644 browser/components/firefoxview/tests/browser/browser_notification_dot.js create mode 100644 browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js create mode 100644 browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js create mode 100644 browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_errors.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_primary_password.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_state.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js create mode 100644 browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js create mode 100644 browser/components/firefoxview/tests/browser/browser_ui_state.js create mode 100644 browser/components/firefoxview/tests/browser/head.js (limited to 'browser/components/firefoxview') diff --git a/browser/components/firefoxview/colorways-card.mjs b/browser/components/firefoxview/colorways-card.mjs new file mode 100644 index 0000000000..31c95c26ba --- /dev/null +++ b/browser/components/firefoxview/colorways-card.mjs @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { NimbusFeatures } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); + +class ColorwaysCard extends HTMLElement { + constructor() { + super(); + this._selectedColorwayId = null; + this._colorwayCollectionName = ""; + this._initPromise = Promise.all([ + this._getSelectedColorway(), + this._getLocalizedStrings(), + ]); + } + + onEnabled(addon) { + if (addon.type == "theme") { + this._selectedColorwayId = BuiltInThemes.isColorwayFromCurrentCollection( + addon.id + ) + ? addon.id + : null; + this._render(); + } + } + + connectedCallback() { + const colorwaysCollection = + NimbusFeatures.majorRelease2022.getVariable("colorwayCloset") && + BuiltInThemes.findActiveColorwayCollection(); + if (!colorwaysCollection) { + this.hidden = true; + return; + } + this.button = this.querySelector("#colorways-button"); + this.collection_title = this.querySelector("#colorways-collection-title"); + this.description = this.querySelector("#colorways-collection-description"); + this.expiry = this.querySelector("#colorways-collection-expiry-date"); + this.figure = this.querySelector("#colorways-collection-figure"); + this.button.addEventListener("click", () => { + const { ColorwayClosetOpener } = ChromeUtils.import( + "resource:///modules/ColorwayClosetOpener.jsm" + ); + ColorwayClosetOpener.openModal({ + source: "firefoxview", + }); + }); + this._initPromise.then(() => this._render()); + AddonManager.addAddonListener(this); + window.addEventListener("unload", () => this.cleanup()); + } + + cleanup() { + AddonManager.removeAddonListener(this); + } + + disconnectedCallback() { + this.cleanup(); + } + + async _getSelectedColorway() { + await BuiltInThemes.ensureBuiltInThemes(); + this._selectedColorwayId = + (await AddonManager.getAddonsByTypes(["theme"])).find( + theme => + theme.isActive && + BuiltInThemes.isColorwayFromCurrentCollection(theme.id) + )?.id || null; + } + + async _getLocalizedStrings() { + let l10nIds = [ + "colorway-intensity-soft", + "colorway-intensity-balanced", + "colorway-intensity-bold", + ]; + const collection = BuiltInThemes.findActiveColorwayCollection(); + if (collection) { + l10nIds.push(collection.l10nId.title); + } + let l10nValues = await document.l10n.formatValues(l10nIds); + if (collection) { + this._colorwayCollectionName = l10nValues.pop(); + } + this._intensityL10nValue = new Map( + l10nValues.map((string, index) => [l10nIds[index], string]) + ); + } + + _render() { + this._showData(this._getData()); + } + + _getData() { + let collection = BuiltInThemes.findActiveColorwayCollection(); + if (!collection) { + return {}; + } + let colorway = null; + if (this._selectedColorwayId) { + colorway = { + name: BuiltInThemes.getLocalizedColorwayGroupName( + this._selectedColorwayId + ), + figureUrl: BuiltInThemes.builtInThemeMap.get(this._selectedColorwayId) + .figureUrl, + intensity: this._intensityL10nValue.get( + BuiltInThemes.getColorwayIntensityL10nId(this._selectedColorwayId) + ), + }; + } + return { + collection, + colorway, + figureUrl: colorway?.figureUrl || collection.figureUrl, + }; + } + + _showData({ collection, colorway, figureUrl }) { + if (colorway) { + this.expiry.hidden = true; + this.collection_title.removeAttribute("data-l10n-id"); + this.collection_title.textContent = colorway.name; + if (colorway.intensity) { + document.l10n.setAttributes( + this.description, + "firefoxview-colorway-description", + { + intensity: colorway.intensity, + collection: this._colorwayCollectionName, + } + ); + } else { + document.l10n.setAttributes(this.description, collection.l10nId.title); + } + document.l10n.setAttributes( + this.button, + "firefoxview-change-colorway-button" + ); + } else { + this.expiry.hidden = false; + document.l10n.setAttributes( + this.expiry.firstElementChild, + "colorway-collection-expiry-label", + { + expiryDate: collection.expiry.getTime(), + } + ); + if (collection.l10nId.description) { + document.l10n.setAttributes( + this.description, + collection.l10nId.description + ); + } + document.l10n.setAttributes( + this.collection_title, + collection.l10nId.title + ); + document.l10n.setAttributes( + this.button, + "firefoxview-try-colorways-button" + ); + } + this.figure.src = figureUrl || ""; + this.hidden = false; + } +} + +customElements.define("colorways-card", ColorwaysCard); diff --git a/browser/components/firefoxview/content/callout-colorways-dark.svg b/browser/components/firefoxview/content/callout-colorways-dark.svg new file mode 100644 index 0000000000..8bc3683aed --- /dev/null +++ b/browser/components/firefoxview/content/callout-colorways-dark.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/firefoxview/content/callout-colorways.svg b/browser/components/firefoxview/content/callout-colorways.svg new file mode 100644 index 0000000000..4096e14278 --- /dev/null +++ b/browser/components/firefoxview/content/callout-colorways.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/firefoxview/content/callout-tab-pickup-dark.svg b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg new file mode 100644 index 0000000000..b38684c38a --- /dev/null +++ b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/firefoxview/content/callout-tab-pickup.svg b/browser/components/firefoxview/content/callout-tab-pickup.svg new file mode 100644 index 0000000000..1ccc36dcc8 --- /dev/null +++ b/browser/components/firefoxview/content/callout-tab-pickup.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/firefoxview/content/cfr-lightning-dark.svg b/browser/components/firefoxview/content/cfr-lightning-dark.svg new file mode 100644 index 0000000000..d2a0369f33 --- /dev/null +++ b/browser/components/firefoxview/content/cfr-lightning-dark.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/firefoxview/content/cfr-lightning.svg b/browser/components/firefoxview/content/cfr-lightning.svg new file mode 100644 index 0000000000..2a6cdc5898 --- /dev/null +++ b/browser/components/firefoxview/content/cfr-lightning.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/firefoxview/content/recently-closed-empty.svg b/browser/components/firefoxview/content/recently-closed-empty.svg new file mode 100644 index 0000000000..014296bae8 --- /dev/null +++ b/browser/components/firefoxview/content/recently-closed-empty.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/browser/components/firefoxview/content/tab-pickup-empty.svg b/browser/components/firefoxview/content/tab-pickup-empty.svg new file mode 100644 index 0000000000..f1c4815120 --- /dev/null +++ b/browser/components/firefoxview/content/tab-pickup-empty.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs new file mode 100644 index 0000000000..97bac34360 --- /dev/null +++ b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This module exports the FirefoxViewNotificationManager singleton, which manages the notification state + * for the Firefox View button + */ + +const RECENT_TABS_SYNC = "services.sync.lastTabFetch"; +const SHOULD_NOTIFY_FOR_TABS = "browser.tabs.firefox-view.notify-for-tabs"; +const lazy = {}; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +export const FirefoxViewNotificationManager = new (class { + #currentlyShowing; + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "lastTabFetch", + RECENT_TABS_SYNC, + 0, + () => { + this.handleTabSync(); + } + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "shouldNotifyForTabs", + SHOULD_NOTIFY_FOR_TABS, + false + ); + // Need to access the pref variable for the observer to start observing + // See the defineLazyPreferenceGetter function header + this.lastTabFetch; + + Services.obs.addObserver(this, "firefoxview-notification-dot-update"); + + this.#currentlyShowing = false; + } + + async handleTabSync() { + if (!this.shouldNotifyForTabs) { + return; + } + let newSyncedTabs = await lazy.SyncedTabs.getRecentTabs(3); + this.#currentlyShowing = this.tabsListChanged(newSyncedTabs); + this.showNotificationDot(); + this.syncedTabs = newSyncedTabs; + } + + showNotificationDot() { + if (this.#currentlyShowing) { + Services.obs.notifyObservers( + null, + "firefoxview-notification-dot-update", + "true" + ); + } + } + + observe(sub, topic, data) { + if (topic === "firefoxview-notification-dot-update" && data === "false") { + this.#currentlyShowing = false; + } + } + + tabsListChanged(newTabs) { + // The first time the tabs list is changed this.tabs is undefined because we haven't synced yet. + // We don't want to show the badge here because it's not an actual change, + // we are just syncing for the first time. + if (!this.syncedTabs) { + return false; + } + + // We loop through all windows to see if any window has currentURI "about:firefoxview" and + // the window is visible because we don't want to show the notification badge in that case + for (let window of lazy.BrowserWindowTracker.orderedWindows) { + // if the url is "about:firefoxview" and the window visible we don't want to show the notification badge + if ( + window.FirefoxViewHandler.tab?.selected && + !window.isFullyOccluded && + window.windowState !== window.STATE_MINIMIZED + ) { + return false; + } + } + + if (newTabs.length > this.syncedTabs.length) { + return true; + } + for (let i = 0; i < newTabs.length; i++) { + let newTab = newTabs[i]; + let oldTab = this.syncedTabs[i]; + + if (newTab?.url !== oldTab?.url) { + return true; + } + } + return false; + } + + shouldNotificationDotBeShowing() { + return this.#currentlyShowing; + } +})(); diff --git a/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs new file mode 100644 index 0000000000..4b925b758b --- /dev/null +++ b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs @@ -0,0 +1,604 @@ +/* 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/. */ + +/** + * This module exports the TabsSetupFlowManager singleton, which manages the state and + * diverse inputs which drive the Firefox View synced tabs setup flow + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "resource://gre/modules/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + UIState: "resource://services-sync/UIState.jsm", + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", + Weave: "resource://services-sync/main.js", +}); + +XPCOMUtils.defineLazyGetter(lazy, "syncUtils", () => { + return ChromeUtils.import("resource://services-sync/util.js").Utils; +}); + +XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm" + ).getFxAccountsSingleton(); +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +const SYNC_TABS_PREF = "services.sync.engine.tabs"; +const TOPIC_TABS_CHANGED = "services.sync.tabs.changed"; +const MOBILE_PROMO_DISMISSED_PREF = + "browser.tabs.firefox-view.mobilePromo.dismissed"; +const LOGGING_PREF = "browser.tabs.firefox-view.logLevel"; +const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; +const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; +const NETWORK_STATUS_CHANGED = "network:offline-status-changed"; +const SYNC_SERVICE_ERROR = "weave:service:sync:error"; +const FXA_ENABLED = "identity.fxaccounts.enabled"; +const FXA_DEVICE_CONNECTED = "fxaccounts:device_connected"; +const FXA_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected"; +const SYNC_SERVICE_FINISHED = "weave:service:sync:finish"; +const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login"; +const TAB_PICKUP_OPEN_STATE_PREF = + "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +function openTabInWindow(window, url) { + const { + switchToTabHavingURI, + } = window.docShell.chromeEventHandler.ownerGlobal; + switchToTabHavingURI(url, true, {}); +} + +export const TabsSetupFlowManager = new (class { + constructor() { + this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]); + + this.setupState = new Map(); + this.resetInternalState(); + this._currentSetupStateName = ""; + this.networkIsOnline = + lazy.gNetworkLinkService.linkStatusKnown && + lazy.gNetworkLinkService.isLinkUp; + this.syncIsWorking = true; + this.syncIsConnected = lazy.UIState.get().syncEnabled; + this.didFxaTabOpen = false; + + this.registerSetupState({ + uiStateIndex: 0, + name: "error-state", + exitConditions: () => { + const fxaStatus = lazy.UIState.get().status; + return ( + this.networkIsOnline && + (this.syncIsWorking || this.syncHasWorked) && + !Services.prefs.prefIsLocked(FXA_ENABLED) && + // it's an error for sync to not be connected if we are signed-in, + // or for sync to be connected if the FxA status is "login_failed", + // which can happen if a user updates their password on another device + ((!this.syncIsConnected && + fxaStatus !== lazy.UIState.STATUS_SIGNED_IN) || + (this.syncIsConnected && + fxaStatus !== lazy.UIState.STATUS_LOGIN_FAILED)) && + // We treat a locked primary password as an error if we are signed-in. + // If the user dismisses the prompt to unlock, they can use the "Try again" button to prompt again + (!this.isPrimaryPasswordLocked || !this.fxaSignedIn) + ); + }, + }); + this.registerSetupState({ + uiStateIndex: 1, + name: "not-signed-in", + exitConditions: () => { + return this.fxaSignedIn; + }, + }); + this.registerSetupState({ + uiStateIndex: 2, + name: "connect-secondary-device", + exitConditions: () => { + return this.secondaryDeviceConnected; + }, + }); + this.registerSetupState({ + uiStateIndex: 3, + name: "disabled-tab-sync", + exitConditions: () => { + return this.syncTabsPrefEnabled; + }, + }); + this.registerSetupState({ + uiStateIndex: 4, + name: "synced-tabs-loaded", + exitConditions: () => { + // This is the end state + return false; + }, + }); + + Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.addObserver(this, TOPIC_DEVICELIST_UPDATED); + Services.obs.addObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.addObserver(this, SYNC_SERVICE_ERROR); + Services.obs.addObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.addObserver(this, TOPIC_TABS_CHANGED); + Services.obs.addObserver(this, PRIMARY_PASSWORD_UNLOCKED); + Services.obs.addObserver(this, FXA_DEVICE_CONNECTED); + Services.obs.addObserver(this, FXA_DEVICE_DISCONNECTED); + + // this.syncTabsPrefEnabled will track the value of the tabs pref + XPCOMUtils.defineLazyPreferenceGetter( + this, + "syncTabsPrefEnabled", + SYNC_TABS_PREF, + false, + () => { + this.maybeUpdateUI(true); + } + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "mobilePromoDismissedPref", + MOBILE_PROMO_DISMISSED_PREF, + false, + () => { + this.maybeUpdateUI(true); + } + ); + + this._lastFxASignedIn = this.fxaSignedIn; + this.logger.debug( + "TabsSetupFlowManager constructor, fxaSignedIn:", + this._lastFxASignedIn + ); + this.onSignedInChange(); + } + + resetInternalState() { + // assign initial values for all the managed internal properties + delete this._lastFxASignedIn; + this._currentSetupStateName = "not-signed-in"; + this._shouldShowSuccessConfirmation = false; + this._didShowMobilePromo = false; + this._waitingForTabs = false; + + this.syncHasWorked = false; + + // keep track of what is connected so we can respond to changes + this._deviceStateSnapshot = { + mobileDeviceConnected: this.mobileDeviceConnected, + secondaryDeviceConnected: this.secondaryDeviceConnected, + }; + } + + get isPrimaryPasswordLocked() { + return lazy.syncUtils.mpLocked(); + } + + getErrorType() { + // this ordering is important for dealing with multiple errors at once + const errorStates = { + "network-offline": !this.networkIsOnline, + "fxa-admin-disabled": Services.prefs.prefIsLocked(FXA_ENABLED), + "password-locked": this.isPrimaryPasswordLocked, + "signed-out": + lazy.UIState.get().status === lazy.UIState.STATUS_LOGIN_FAILED, + "sync-disconnected": !this.syncIsConnected, + "sync-error": !this.syncIsWorking && !this.syncHasWorked, + }; + + for (let [type, value] of Object.entries(errorStates)) { + if (value) { + return type; + } + } + return null; + } + + uninit() { + Services.obs.removeObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.removeObserver(this, TOPIC_DEVICELIST_UPDATED); + Services.obs.removeObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.removeObserver(this, SYNC_SERVICE_ERROR); + Services.obs.removeObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.removeObserver(this, TOPIC_TABS_CHANGED); + Services.obs.removeObserver(this, PRIMARY_PASSWORD_UNLOCKED); + Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED); + Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED); + } + get currentSetupState() { + return this.setupState.get(this._currentSetupStateName); + } + get isTabSyncSetupComplete() { + return this.currentSetupState.uiStateIndex >= 4; + } + get uiStateIndex() { + return this.currentSetupState.uiStateIndex; + } + get fxaSignedIn() { + let { UIState } = lazy; + let syncState = UIState.get(); + return ( + UIState.isReady() && + syncState.status === UIState.STATUS_SIGNED_IN && + // syncEnabled just checks the "services.sync.username" pref has a value + syncState.syncEnabled + ); + } + get secondaryDeviceConnected() { + if (!this.fxaSignedIn) { + return false; + } + let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length; + return recentDevices > 1; + } + get mobileDeviceConnected() { + if (!this.fxaSignedIn) { + return false; + } + let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter( + device => device.type == "mobile" || device.type == "tablet" + ); + return mobileClients?.length > 0; + } + get shouldShowMobilePromo() { + return ( + this.syncIsConnected && + this.fxaSignedIn && + this.currentSetupState.uiStateIndex >= 4 && + !this.mobileDeviceConnected && + !this.mobilePromoDismissedPref + ); + } + get shouldShowMobileConnectedSuccess() { + return ( + this.currentSetupState.uiStateIndex >= 3 && + this._shouldShowSuccessConfirmation && + this.mobileDeviceConnected + ); + } + get logger() { + if (!this._log) { + let setupLog = lazy.Log.repository.getLogger("FirefoxView.TabsSetup"); + setupLog.manageLevelFromPref(LOGGING_PREF); + setupLog.addAppender( + new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) + ); + this._log = setupLog; + } + return this._log; + } + + registerSetupState(state) { + this.setupState.set(state.name, state); + } + + async observe(subject, topic, data) { + switch (topic) { + case lazy.UIState.ON_UPDATE: + this.logger.debug("Handling UIState update"); + this.syncIsConnected = lazy.UIState.get().syncEnabled; + if (this._lastFxASignedIn !== this.fxaSignedIn) { + this.onSignedInChange(); + } else { + this.maybeUpdateUI(); + } + this._lastFxASignedIn = this.fxaSignedIn; + break; + case TOPIC_DEVICELIST_UPDATED: + this.logger.debug("Handling observer notification:", topic, data); + if (await this.refreshDevices()) { + this.logger.debug( + "refreshDevices made changes, calling maybeUpdateUI" + ); + this.maybeUpdateUI(true); + } + break; + case FXA_DEVICE_CONNECTED: + case FXA_DEVICE_DISCONNECTED: + await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); + this.maybeUpdateUI(true); + break; + case SYNC_SERVICE_ERROR: + this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`); + if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) { + this._waitingForTabs = false; + this.syncIsWorking = false; + this.maybeUpdateUI(true); + } + break; + case NETWORK_STATUS_CHANGED: + this.networkIsOnline = data == "online"; + this._waitingForTabs = false; + this.maybeUpdateUI(true); + break; + case SYNC_SERVICE_FINISHED: + this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`); + this._waitingForTabs = false; + if (!this.syncIsWorking) { + this.syncIsWorking = true; + this.syncHasWorked = true; + } + this.maybeUpdateUI(true); + break; + case TOPIC_TABS_CHANGED: + this.stopWaitingForTabs(); + break; + case PRIMARY_PASSWORD_UNLOCKED: + this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`); + this.tryToClearError(); + break; + } + } + + get waitingForTabs() { + return ( + // signed in & at least 1 other device is sycning indicates there's something to wait for + this.secondaryDeviceConnected && + // last recent tabs request came back empty and we've not had a sync finish (or error) yet + this._waitingForTabs + ); + } + + startWaitingForTabs() { + if (!this._waitingForTabs) { + this._waitingForTabs = true; + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + stopWaitingForTabs() { + if (this._waitingForTabs) { + this._waitingForTabs = false; + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + async onSignedInChange() { + this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn); + // update UI to make the state change + this.maybeUpdateUI(true); + if (!this.fxaSignedIn) { + // As we just signed out, ensure the waiting flag is reset for next time around + this._waitingForTabs = false; + return; + } + + // Set Tab pickup open state pref to true when signing in + Services.prefs.setBoolPref(TAB_PICKUP_OPEN_STATE_PREF, true); + + // Now we need to figure out if we have recently synced tabs to show + // Or, if we are going to need to trigger a tab sync for them + const recentTabs = await lazy.SyncedTabs.getRecentTabs(50); + + if (!this.fxaSignedIn) { + // We got signed-out in the meantime. We should get an ON_UPDATE which will put us + // back in the right state, so we just do nothing here + return; + } + + // When SyncedTabs has resolved the getRecentTabs promise, + // we also know we can update devices-related internal state + if (await this.refreshDevices()) { + this.logger.debug( + "onSignedInChange, after refreshDevices, calling maybeUpdateUI" + ); + // give the UI an opportunity to update as secondaryDeviceConnected or + // mobileDeviceConnected have changed value + this.maybeUpdateUI(true); + } + + // If we can't get recent tabs, we need to trigger a request for them + const tabSyncNeeded = !recentTabs?.length; + this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded); + + if (tabSyncNeeded) { + this.startWaitingForTabs(); + this.logger.debug( + "isPrimaryPasswordLocked:", + this.isPrimaryPasswordLocked + ); + this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs"); + // If the syncTabs call rejects or resolves false we need to clear the waiting + // flag and update UI + this.syncTabs() + .catch(ex => { + this.logger.debug("onSignedInChange, syncTabs rejected:", ex); + this.stopWaitingForTabs(); + }) + .then(willSync => { + if (!willSync) { + this.logger.debug("onSignedInChange, no tab sync expected"); + this.stopWaitingForTabs(); + } + }); + } + } + + async refreshDevices() { + // If current device not found in recent device list, refresh device list + if ( + !lazy.fxAccounts.device.recentDeviceList?.some( + device => device.isCurrentDevice + ) + ) { + await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); + } + + // compare new values to the previous values + const mobileDeviceConnected = this.mobileDeviceConnected; + const secondaryDeviceConnected = this.secondaryDeviceConnected; + + this.logger.debug( + `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `, + `secondaryDeviceConnected: ${secondaryDeviceConnected}` + ); + + let didDeviceStateChange = + this._deviceStateSnapshot.mobileDeviceConnected != + mobileDeviceConnected || + this._deviceStateSnapshot.secondaryDeviceConnected != + secondaryDeviceConnected; + if ( + mobileDeviceConnected && + !this._deviceStateSnapshot.mobileDeviceConnected + ) { + // a mobile device was added, show success if we previously showed the promo + this._shouldShowSuccessConfirmation = this._didShowMobilePromo; + } else if ( + !mobileDeviceConnected && + this._deviceStateSnapshot.mobileDeviceConnected + ) { + // no mobile device connected now, reset + Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF); + this._shouldShowSuccessConfirmation = false; + } + this._deviceStateSnapshot = { + mobileDeviceConnected, + secondaryDeviceConnected, + }; + if (didDeviceStateChange) { + this.logger.debug("refreshDevices: device state did change"); + if (!secondaryDeviceConnected) { + this.logger.debug( + "We lost a device, now claim sync hasn't worked before." + ); + this.syncHasWorked = false; + } + } else { + this.logger.debug("refreshDevices: no device state change"); + } + return didDeviceStateChange; + } + + maybeUpdateUI(forceUpdate = false) { + let nextSetupStateName = this._currentSetupStateName; + let errorState = null; + let stateChanged = false; + + // state transition conditions + for (let state of this.setupState.values()) { + nextSetupStateName = state.name; + if (!state.exitConditions()) { + this.logger.debug( + "maybeUpdateUI, conditions not met to exit state: ", + nextSetupStateName + ); + break; + } + } + + let setupState = this.currentSetupState; + const state = this.setupState.get(nextSetupStateName); + const uiStateIndex = state.uiStateIndex; + + if ( + uiStateIndex == 0 || + nextSetupStateName != this._currentSetupStateName + ) { + setupState = state; + this._currentSetupStateName = nextSetupStateName; + stateChanged = true; + } + this.logger.debug( + "maybeUpdateUI, will notify update?:", + stateChanged, + forceUpdate + ); + if (stateChanged || forceUpdate) { + if (this.shouldShowMobilePromo) { + this._didShowMobilePromo = true; + } + if (uiStateIndex == 0) { + errorState = this.getErrorType(); + this.logger.debug("maybeUpdateUI, in error state:", errorState); + } + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState); + } + if ("function" == typeof setupState.enter) { + setupState.enter(); + } + } + + dismissMobilePromo() { + Services.prefs.setBoolPref(MOBILE_PROMO_DISMISSED_PREF, true); + } + + dismissMobileConfirmation() { + this._shouldShowSuccessConfirmation = false; + this._didShowMobilePromo = false; + this.maybeUpdateUI(true); + } + + async openFxASignup(window) { + if (!(await lazy.fxAccounts.constructor.canConnectAccount())) { + return; + } + const url = await lazy.fxAccounts.constructor.config.promiseConnectAccountURI( + "fx-view" + ); + this.didFxaTabOpen = true; + openTabInWindow(window, url, true); + Services.telemetry.recordEvent("firefoxview", "fxa_continue", "sync", null); + } + + async openFxAPairDevice(window) { + const url = await lazy.fxAccounts.constructor.config.promisePairingURI({ + entrypoint: "fx-view", + }); + this.didFxaTabOpen = true; + openTabInWindow(window, url, true); + Services.telemetry.recordEvent("firefoxview", "fxa_mobile", "sync", null, { + has_devices: this.secondaryDeviceConnected.toString(), + }); + } + + syncOpenTabs(containerElem) { + // Flip the pref on. + // The observer should trigger re-evaluating state and advance to next step + Services.prefs.setBoolPref(SYNC_TABS_PREF, true); + } + + async syncOnPageReload() { + if (lazy.UIState.isReady() && this.fxaSignedIn) { + this.startWaitingForTabs(); + await this.syncTabs(true); + } + } + + tryToClearError() { + if (lazy.UIState.isReady() && this.fxaSignedIn) { + this.startWaitingForTabs(); + Services.tm.dispatchToMainThread(() => { + this.logger.debug("tryToClearError: triggering new tab sync"); + this.startFullTabsSync(); + }); + } else { + this.logger.debug( + `tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${ + this.fxaSignedIn + }` + ); + } + } + // For easy overriding in tests + syncTabs(force = false) { + return lazy.SyncedTabs.syncTabs(force); + } + + startFullTabsSync() { + lazy.Weave.Service.sync({ why: "tabs", engines: ["tabs"] }); + } +})(); diff --git a/browser/components/firefoxview/firefoxview.css b/browser/components/firefoxview/firefoxview.css new file mode 100644 index 0000000000..dbb3024a2b --- /dev/null +++ b/browser/components/firefoxview/firefoxview.css @@ -0,0 +1,1049 @@ +/* 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/. */ + +:host, +:root { + --content-area-padding-inline: 24px; + --content-area-padding-block: 16px; + --header-spacing: 40px; + --footer-spacing: 80px; + + --success-fill-color: #2AC3A2; + --success-background-color: #87FFD1; + --success-box-text-color: #15141A; + + --details-grid-column: 1; + --recently-closed-tabs-grid-row: 2; + --colorways-grid-column: 2; + --colorways-grid-row: 1 / 3; + + --colorways-figure-size: 225px; + --colorways-figure-margin: 0 0 1.5em; + --colorways-grid-template-areas: + "colorways-figure" + "colorways-header" + "colorways-description" + "colorways-button"; + --colorways-grid-template-columns: auto; + --colorways-grid-template-rows: auto auto auto 1fr; + --colorways-figure-display: flex; + --colorways-header-flex-direction: column; + + --info-icon-background-color: #0090ED; +} + +:root { + /* align the base font-size on root element with that of + so rem-based layout widths and break-points behave predictably */ + font-size: 15px; + /* override --in-content-page-background from common-shared.css */ + background-color: transparent; +} + +body { + --fxview-background-color: var(--newtab-background-color, var(--in-content-page-background)); + --fxview-element-background-hover: color-mix(in srgb, var(--fxview-background-color) 90%, currentColor); + --fxview-element-background-active: color-mix(in srgb, var(--fxview-background-color) 80%, currentColor); + --fxview-text-primary-color: var(--newtab-text-primary-color, var(--in-content-page-color)); + --fxview-text-color-hover: var(--newtab-text-primary-color); + --fxview-contrast-border: color-mix(in hsl, currentColor 45%, transparent); + --fxview-extra-contrast-border: color-mix(in hsl, currentColor 85%, transparent); + --in-content-zap-gradient: linear-gradient(var(--fxview-extra-contrast-border), var(--fxview-extra-contrast-border)); + --card-border-zap-gradient: var(--in-content-zap-gradient); + --fxview-text-secondary-color: color-mix(in srgb, currentColor 70%, transparent); + --newtab-background-color-secondary: #FFF; + + /* ensure utility button hover states match those of the rest of the page */ + --in-content-button-background-hover: var(--fxview-element-background-hover); + --in-content-button-background-active: var(--fxview-element-background-active); + --in-content-button-text-color-hover: var(--fxview-text-color-hover); + + display: flex; + justify-content: center; + padding-block: var(--header-spacing) var(--footer-spacing); + padding-inline: var(--content-area-padding-inline); + max-width: 96rem; + margin-inline: auto; + background-color: var(--fxview-background-color); + color: var(--newtab-text-primary-color); +} + +body:not([lwt-newtab]) { + --in-content-zap-gradient: linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%); +} + +@media (prefers-color-scheme: dark) { + body { + --fxview-element-background-hover: color-mix(in srgb, var(--fxview-background-color) 80%, white); + --fxview-element-background-active: color-mix(in srgb, var(--fxview-background-color) 60%, white); + --newtab-background-color-secondary: #42414d; + } +} + +@media (prefers-contrast) { + body { + --fxview-element-background-hover: ButtonText; + --fxview-element-background-active: ButtonText; + --fxview-text-color-hover: ButtonFace; + --fxview-text-secondary-color: currentColor; + } +} + +h1 { + color: var(--fxview-text-primary-color); + font-weight: 600; + font-size: 1.5em; +} + +.content-container { + padding-inline: var(--content-area-padding-inline); + padding-block: var(--content-area-padding-block); +} + +#logo-container { + flex: 0 0 auto; +} + +body > main { + flex: 1 1 auto; + display: grid; + grid-template-columns: 2fr 1fr; + grid-template-rows: max-content 1fr; +} + +body > main > details { + grid-column: var(--details-grid-column); +} + +@media (max-width: 76rem) { + :host, + :root { + --content-area-padding-inline: 12px; + } + .brand-logo > .brand-feature-name { + display: none; + } +} + +@media (max-width: 65rem) { + :root { + --recently-closed-tabs-grid-row: 3; + --details-grid-column: 1 / -1; + --colorways-grid-column: 1 / -1; + --colorways-grid-row: 2; + + --colorways-grid-template-areas: + "colorways-header colorways-figure" + "colorways-description colorways-figure" + "colorways-button colorways-figure"; + --colorways-grid-template-columns: 1fr var(--colorways-figure-size); + --colorways-grid-template-rows: min-content min-content 1fr; + --colorways-header-flex-direction: row; + --colorways-figure-margin: 0; + } +} + +@media (max-width: 45rem) { + :host, + :root { + --header-spacing: 16px; + --footer-spacing: 16px; + --colorways-grid-template-areas: + "colorways-header" + "colorways-description" + "colorways-button"; + --colorways-grid-template-columns: auto; + --colorways-grid-template-rows: auto; + --colorways-figure-display: none; + --colorways-header-flex-direction: column; + } +} + +@media (max-width: 28rem) { + body { + flex-wrap: wrap; + } +} + +.brand-logo { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 1.75em; + white-space: nowrap; +} + +.brand-logo > .brand-icon { + display: inline-block; + background: url("chrome://branding/content/about-logo.png") no-repeat center; + background-size: 32px; + min-width: 40px; + height: 32px; +} + +.brand-logo > .brand-feature-name { + margin-inline-start: 8px; + flex: 1 1 auto; + font-weight: 600; +} + +#colorways:not([hidden]) { + display: grid; + grid-column: var(--colorways-grid-column); + grid-row: var(--colorways-grid-row); + grid-template-areas: var(--colorways-grid-template-areas); + grid-template-columns: var(--colorways-grid-template-columns); + grid-template-rows: var(--colorways-grid-template-rows); + justify-items: start; + align-items: start; + padding-inline: calc(2 * var(--content-area-padding-inline)); + padding-block: calc(2 * var(--content-area-padding-block)); +} + +#colorways > header { + grid-area: colorways-header; + display: flex; + flex-direction: var(--colorways-header-flex-direction); + align-items: flex-start; + flex-wrap: wrap; +} + +#colorways-collection-description { + grid-area: colorways-description; +} + +#colorways-collection-description, +#colorways-button { + margin: 0.4em 0; +} + +#colorways-button { + grid-area: colorways-button; +} + +#colorways-collection-title { + margin: 0; + margin-top: 0.3em; + margin-inline-end: 0.5em; + padding: 0; +} + +#colorways-collection-expiry-date { + display: inline-block; + background: var(--card-border-zap-gradient); + background-origin: border-box; + border: 1px solid transparent; + border-radius: 1.5em; + margin: 0.8em 0; +} + +#colorways-collection-expiry-date > span { + display: inline-block; + background: var(--fxview-background-color); + border-radius: 1.5em; + padding: .3em 1em; +} + +#colorways > figure { + display: var(--colorways-figure-display); + grid-area: colorways-figure; + align-items: center; + justify-content: center; + width: var(--colorways-figure-size); + height: var(--colorways-figure-size); + margin: var(--colorways-figure-margin); +} + +#colorways-collection-figure { + max-width: var(--colorways-figure-size); + max-height: var(--colorways-figure-size); + object-fit: scale-down; +} + +[hidden] { + display: none !important; +} + +button.ghost-button, +button.ghost-button:not(.semi-transparent):enabled:is(:hover, :active) { + color: inherit; +} + +@media (prefers-contrast) { + button.ghost-button:not(.semi-transparent):enabled:is(:hover, :active) { + background-color: ButtonText; + color: ButtonFace; + } +} + +button.primary { + white-space: nowrap; + min-width: fit-content; +} + +button.close { + background-image: url(chrome://global/skin/icons/close.svg); + -moz-context-properties: fill; + fill: currentColor; +} + +.card, +.synced-tab-a, +.synced-tab-li-placeholder, +.empty-container { + background-color: var(--newtab-background-color-secondary); + border: 1px solid var(--fxview-contrast-border); +} + +#collapsible-tabs-container, +#tabpickup-tabs-container { + margin-block-start: 0.5em; +} + +.empty-container { + border-radius: 4px; +} + +.error-state { + text-align: center; + padding-block: 0 1.3em; + padding-inline: 1em; + border: 1px solid var(--fxview-contrast-border); + border-radius: 4px; +} + +.error-state > h3 { + font-weight: 600; + display: inline-block; + margin-bottom: 0; +} + +.placeholder-content { + color: var(--fxview-text-secondary-color); + display: flex; + padding: 1.8em 1.1em; +} + +#recently-closed-empty-image, +#tab-pickup-empty-image { + margin-inline-end: 1.1em; + -moz-context-properties: fill, stroke, fill-opacity; + fill: var(--fxview-background-color); + stroke: var(--fxview-text-primary-color); + fill-opacity: 0.07; +} + +@media (prefers-color-scheme: dark) { + #recently-closed-empty-image, + #tab-pickup-empty-image { + fill: var(--newtab-background-color-secondary); + fill-opacity: 0.15; + } +} + +.placeholder-text { + margin: 0; +} + +.placeholder-header { + margin-block: 0 0.27em; + font-weight: 600; +} + +.placeholder-body { + margin-block: 0; + line-height: 1.3em; +} + +.page-section-header { + column-gap: 16px; + cursor: pointer; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + grid-template-areas: + "twisty head" + "none desc"; + list-style-type: none; + position: relative; + z-index: 1; +} + +@media (prefers-contrast) { + .page-section-header { + color: WindowText; + } + .page-section-header:focus-visible { + box-shadow: none; + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); + } +} + +.page-section-header > h1 { + grid-area: head; + margin: 0; + padding-block: 4px; +} + +/* the twisty is just an ornament; the whole summary parent node is clickable */ +.page-section-header > .twisty { + background-image: url("chrome://global/skin/icons/arrow-right.svg"); + display: inline-block; + grid-area: twisty; + align-self: center; + justify-self: start; + padding-block: 4px; + padding-inline: 8px; + fill: currentColor; + border-radius: 4px; + margin-block: 0; +} + +[dir="rtl"] .page-section-header > .twisty { + background-image: url("chrome://global/skin/icons/arrow-left.svg"); +} + +@media (prefers-contrast) { + .page-section-header > .twisty { + border: 1px solid ButtonText; + } +} + +details[open] > .page-section-header > .twisty { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); +} + +.page-section-header:hover > .twisty { + background-color: var(--fxview-element-background-hover); + color: var(--fxview-text-color-hover); +} + +.page-section-header:hover:active > .twisty { + background-color: var(--fxview-element-background-active); +} + +.page-section-header > .section-description { + grid-area: desc; + margin-block: 4px 8px; +} + +.card-body { + display: flex; + flex-grow: 1; + align-content: space-between; + align-items: center; + gap: 8px; +} +@media only screen and (max-width: 45rem) { + .card-body { + flex-wrap: wrap; + } +} + +.card-body > button.primary { + margin-inline-start: 0; + z-index: 1; +} + +.card-body > .step-content, +.zap-card > button.close { + z-index: 1; +} + +.setup-step { + padding: var(--card-padding); + margin-block: 0.5em 1em; +} + +/* Bug 1770534 - Only use the zap-gradient for built-in, color-neutral themes */ +.zap-card { + border: none; + position: relative; +} +.zap-card::before { + content: ""; + position: absolute; + inset: 0; + border: 1px solid transparent; + border-radius: 4px; + background-origin: border-box; + background-image: var(--card-border-zap-gradient); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; +} + +.setup-step > h2 { + margin-block: 0 8px; +} + +.setup-step > .card-body { + margin-block: 8px; + padding-block: 8px; +} +.setup-step > .card-body > .step-content { + flex: 1 1 auto; +} + +.setup-step > footer { + display: flex; + flex-direction: column; + margin-block: 0 8px; +} + +.step-progress { + background-color: #E0E0E6; + border-radius: 8px; + border-style: none; + height: 8px; + margin-block: 0 8px; + margin-inline: 0 2px; +} + +.step-progress::-moz-progress-bar { + background-color: var(--success-fill-color); + border-radius: 8px; +} + +@media (prefers-contrast) { + .step-progress { + background-color: SelectedItemText; + border: 1px solid SelectedItem; + } + + .step-progress::-moz-progress-bar { + background-color: SelectedItem; + } +} + +.message-box { + display: flex; + align-items: center; + margin-block: 8px; + gap: 8px; +} + +.message-content { + flex: 1 1 auto; +} + +.message-content > .message-header { + font-size: 1em; + margin-block: 0 0.33em; +} + +.message-content > .message-description { + margin-block: 0 0.33em; +} + +.confirmation-message-box { + background-color: var(--success-background-color); + color: var(--success-box-text-color); + border-color: var(--success-fill-color); +} +.confirmation-message-box > .message-content { + text-align: center; +} +.confirmation-message-box > .message-content > .message-header { + font-size: inherit; + display: inline; +} +/* ensure we get the local color values as container doesnt change color with theme */ +.confirmation-message-box > .icon-button { + color: inherit; +} +.confirmation-message-box > button.icon-button:enabled:is(:hover, :active) { + background-color: color-mix(in srgb, var(--success-background-color) 90%, currentColor); +} +@media (prefers-contrast) { + .confirmation-message-box > button.icon-button { + border-color: ButtonText; + } + .confirmation-message-box > button.icon-button:enabled:is(:hover, :active) { + background-color: ButtonText; + color: ButtonFace; + } +} + +#tab-pickup-container { + grid-row: 1; +} + +/* 117px is the total height of the collapsible-tabs-container; setting that size + for the second row stabilizes the layout so it doesn't shift when collapsibled */ +#recently-closed-tabs-container { + grid-row: var(--recently-closed-tabs-grid-row); + display: grid; + grid-template-rows: max-content 117px; +} + +#recently-closed-tabs-container > p { + margin-top: 0; +} + +.synced-tabs-container.loading > .card, +.synced-tabs-container.loading > tab-pickup-list, +.synced-tabs-container.loading > .placeholder-content, +.synced-tabs-container:not(.loading) > .loading-content { + display: none; +} + +.synced-tabs-container > .loading-content { + text-align: center; + color: var(--fxview-text-secondary-color); + margin-top: 40px; + padding: 20px 16px 16px; +} + +.closed-tabs-list { + padding-inline-start: 0; + margin-block-start: 0; + display: grid; + grid-template-columns: min-content repeat(5, 1fr) repeat(2, min-content); + column-gap: 8px; + row-gap: 8px; +} + +.closed-tab-li { + display: grid; + grid-template-columns: subgrid; + grid-column: span 8; + margin-block-end: 0.5em; + border-radius: 4px; + align-items: center; +} + +.closed-tab-li-main { + display: grid; + grid-template-columns: subgrid; + grid-column: span 7; + padding: 0.5em; + cursor: pointer; + align-items: center; + user-select: none; + border-radius: 4px; +} + +@media (prefers-contrast) { + span.closed-tab-li-main, + button.closed-tab-li-dismiss { + color: ButtonText; + border-radius: 4px; + border: 1px solid ButtonText; + } +} + +.closed-tab-li-main:hover { + background-color: var(--fxview-element-background-hover); + color: var(--fxview-text-color-hover); +} + +.closed-tab-li-main:hover .closed-tab-li-title { + text-decoration-line: underline; +} + +.closed-tab-li-main:active { + background-color: var(--fxview-element-background-active); + color: var(--fxview-text-color-hover); +} + +.closed-tab-li-main:focus-visible { + box-shadow: none; + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); + border-radius: 4px; +} + +.closed-tab-li-title { + padding-inline-start: 10px; + font-weight: 500; + grid-column: span 3; +} + +.closed-tab-li-url { + padding-inline-start: 8px; + text-decoration-line: underline; + grid-column: span 2; +} + +.closed-tab-li-time { + white-space: nowrap; + text-align: end; +} + +.closed-tab-li-dismiss { + background-image: url("chrome://global/skin/icons/close.svg"); + background-repeat: no-repeat; + background-position: center; + background-color: transparent; + color: var(--fxview-text-secondary-color); + -moz-context-properties: fill; + fill: var(--fxview-text-secondary-color); + min-width: 33px; + padding: 0.5em; + margin: 0; + cursor: pointer; + user-select: none; +} + +.closed-tab-li-dismiss:hover { + background-color: var(--in-content-button-background-hover); + fill: var(--in-content-button-text-color-hover); +} + +.synced-tab-a, +.synced-tab-a:hover, +.synced-tab-a:active, +.synced-tab-a:hover:active, +.synced-tab-a:visited { + color: inherit; + text-decoration: none; + height: 100%; +} + +@media (prefers-contrast) { + .synced-tab-a { + border-color: FieldText; + } + .synced-tab-a, + .synced-tab-a:hover, + .synced-tab-a:active, + .synced-tab-a:hover:active, + .synced-tab-a:visited { + color: LinkText; + } + .synced-tab-a:focus-visible { + box-shadow: none; + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); + } +} + +.closed-tab-li-url, +.closed-tab-li-time, +.synced-tab-li-device, +.synced-tab-li-url, +.synced-tab-li-time { + font-weight: 400; + color: var(--fxview-text-secondary-color); +} + +.closed-tab-li-title, +.closed-tab-li-url, +.synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-title, +.synced-tab-li-device { + overflow: hidden; +} + +.closed-tab-li-title, +.synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-title, +.synced-tab-li-device { + text-overflow: ellipsis; + white-space: nowrap; +} + +.synced-tab-li-url, +.closed-tab-li-url { + word-break: break-word; +} + +.synced-tabs-list { + padding: 0; + margin-block-start: 0; + list-style: none; + display: grid; + grid-template-columns: 4fr 4fr; + column-gap: 16px; + row-gap: 8px; + + grid-template-areas: + "first second" + "first third"; +} + +@media only screen and (max-width: 43rem) { + .synced-tabs-list { + grid-template-columns: 1fr; + grid-template-areas: + "first" + "second" + "third"; + } + + body { + flex-flow: column; + } + + #logo-container .brand-logo { + justify-content: center; + } +} + +.synced-tab-a, +.synced-tab-li-placeholder { + box-sizing: border-box; + border-radius: 4px; + padding: 7px; + display: grid; + column-gap: 8px; + row-gap: 2px; + align-items: center; + grid-template-columns: min-content repeat(2, 1fr) minmax(min-content, auto); + grid-template-rows: auto 1fr auto; + grid-template-areas: + "favicon title title title" + "favicon domain domain domain" + "favicon device device time" +} + +.synced-tab-a:hover { + box-shadow: 0px 2px 14px var(--fxview-contrast-border); +} + +.synced-tab-li:not(:first-child) > .synced-tab-a { + align-content: center; +} + +@media only screen and (max-width: 60rem) { + .synced-tab-li > .synced-tab-a, + .synced-tab-li-placeholder { + grid-template-areas: + "favicon title title title" + "favicon domain domain domain" + "favicon device device device"; + } + .synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-time { + display: none; + } +} + +.synced-tab-li-placeholder { + row-gap: 1em; + grid-template-areas: + "favicon title title title" + "favicon domain domain domain"; + grid-template-rows: auto auto; +} + +.li-placeholder-favicon { + grid-area: favicon; + align-self: start; + width: 16px; + height: 16px; +} + +.li-placeholder-title { + grid-area: title; + height: .8em; + margin-block: .1em; /* simulate line-height */ + width: 100%; +} + +.li-placeholder-domain { + grid-area: domain; + height: .6em; + margin-block: .1em; /* simulate line-height */ + width: 100%; +} + +.li-placeholder-favicon, +.li-placeholder-title, +.li-placeholder-domain { + display: inline-block; + background-color: currentColor; opacity: 0.08; + border-radius: 4px; +} + +.synced-tab-li:first-child { + grid-area: first; +} + +.synced-tab-li:first-child > .synced-tab-a { + padding: 15px; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: auto auto 1fr auto; + grid-template-areas: + "favicon badge badge badge" + "title title title title" + "domain domain domain domain" + "device device device time"; + row-gap: 4px; +} + +.synced-tab-li:nth-child(2) { + grid-area: second; +} + +.synced-tab-li:nth-child(3) { + grid-area: third; +} + +.synced-tab-li-url, +.synced-tab-li-device, +.synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-title { + font-size: .85em; +} + +.synced-tab-li-time { + font-size: .75em; +} + +.synced-tab-li-url { + text-decoration-line: underline; + grid-area: domain; + align-self: start; +} + +.synced-tab-li-title { + grid-area: title; + font-weight: 500; +} + +.synced-tab-li:first-child > .synced-tab-a > .synced-tab-li-title { + color: inherit; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + align-self: start; +} + +.synced-tab-li-device { + grid-area: device; +} + +.synced-tab-li-time { + grid-area: time; + justify-self: end; + align-self: end; + white-space: nowrap; +} + +.synced-tab-li:first-child > .synced-tab-a > .synced-tab-li-time { + align-self: center; +} + +.synced-tab-li .favicon { + grid-area: favicon; + align-self: start; +} + +@media (prefers-contrast) { + .synced-tab-li .favicon { + color: LinkText; + } +} + +.synced-tab-li .icon { + vertical-align: bottom; + margin-inline-end: 5px; +} + +.icon { + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + -moz-context-properties: fill; + fill: currentColor; +} + +.history { + background-image: url('chrome://browser/skin/history.svg'); +} + +.phone { + background-image: url('chrome://browser/skin/device-phone.svg'); +} + +.desktop { + background-image: url('chrome://browser/skin/device-desktop.svg'); +} + +.tablet { + background-image: url('chrome://browser/skin/device-tablet.svg'); +} + +.synced-tabs { + background-image: url('chrome://browser/skin/synced-tabs.svg'); +} + +.info { + background-image: url('chrome://global/skin/icons/info-filled.svg'); +} + +.error-state > .info { + vertical-align: text-top; + margin-inline-end: 7px; + margin-top: 1px; + color: var(--info-icon-background-color); +} + +.favicon { + background-size: cover; + -moz-context-properties: fill; + fill: currentColor; +} + +.favicon, .icon, .synced-tab-li-favicon { + width: 16px; + height: 16px; +} + +.sync { + background-image: url(chrome://browser/skin/sync.svg); + background-size: cover; + height: 19px; + width: 19px; + color: var(--fxview-text-secondary-color); +} + +@keyframes syncRotate { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: no-preference) { + .sync { + animation: syncRotate 0.8s linear infinite; + } +} + +.last-active-badge { + height: 1.25em; + background-color: #E3FFF3; + grid-area: badge; + border-radius: 2em; + justify-self: end; + text-align: center; + padding: 0.3em 1em; + font-size: 0.75em; +} + +.dot { + height: 8px; + width: 8px; + background-color: var(--success-fill-color); + border-radius: 50%; + display: inline-block; +} + +.badge-text { + font-weight: 400; + letter-spacing: 0.02em; + margin-inline-start: 4px; + color: #000000; +} + +@media (prefers-contrast) { + .last-active-badge { + border: 1px solid CanvasText; + background-color: Canvas; + } + .dot { + background-color: FieldText; + } + .badge-text { + color: FieldText; + } +} diff --git a/browser/components/firefoxview/firefoxview.html b/browser/components/firefoxview/firefoxview.html new file mode 100644 index 0000000000..749a8564a4 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.html @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + +

+

+
+ + + +
+

+
+

+ + +

+ +
+
+
+

+
+

+ +
+
+ + +
+
+
+

+
+

+ +
+ +

+ +
+
+ + +
+
+
+

+
+

+ +
+ +

+ +
+
+ + +
+
+
+ + + + +
+ + + +
+ + +

+

+
+
+ + + + +
+
+
+ + diff --git a/browser/components/firefoxview/firefoxview.mjs b/browser/components/firefoxview/firefoxview.mjs new file mode 100644 index 0000000000..3500e2db59 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.mjs @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { FeatureCallout } = ChromeUtils.importESModule( + "resource:///modules/FeatureCallout.sys.mjs" +); + +const MediaQueryDOMSorting = { + init() { + this.recentlyClosedTabs = document.getElementById( + "recently-closed-tabs-container" + ); + this.colorways = document.getElementById("colorways"); + this.mql = window.matchMedia("(max-width: 65rem)"); + this.mql.addEventListener("change", () => this.changeHandler()); + this.changeHandler(); + }, + cleanup() { + this.mql.removeEventListener("change", () => this.changeHandler()); + }, + changeHandler() { + const oldFocus = document.activeElement; + if (this.mql.matches) { + this.recentlyClosedTabs.before(this.colorways); + } else { + this.colorways.before(this.recentlyClosedTabs); + } + if (oldFocus) { + Services.focus.setFocus(oldFocus, Ci.nsIFocusManager.FLAG_NOSCROLL); + } + }, +}; + +const launchFeatureTour = () => { + let callout = new FeatureCallout({ + win: window, + prefName: "browser.firefox-view.feature-tour", + }); + callout.showFeatureCallout(); +}; + +window.addEventListener("DOMContentLoaded", async () => { + Services.telemetry.setEventRecordingEnabled("firefoxview", true); + Services.telemetry.recordEvent("firefoxview", "entered", "firefoxview", null); + document.getElementById("recently-closed-tabs-container").onLoad(); + MediaQueryDOMSorting.init(); + // If Firefox View was reloaded by the user, force syncing of tabs + // to get the most up to date synced tabs. + if ( + performance + .getEntriesByType("navigation") + .map(nav => nav.type) + .includes("reload") + ) { + await document.getElementById("tab-pickup-container").onReload(); + } + launchFeatureTour(); +}); + +window.addEventListener("unload", () => { + const tabPickupList = document.querySelector("tab-pickup-list"); + if (tabPickupList) { + tabPickupList.cleanup(); + } + document.getElementById("tab-pickup-container").cleanup(); + document.getElementById("recently-closed-tabs-container").cleanup(); + MediaQueryDOMSorting.cleanup(); +}); diff --git a/browser/components/firefoxview/helpers.mjs b/browser/components/firefoxview/helpers.mjs new file mode 100644 index 0000000000..b4df480f73 --- /dev/null +++ b/browser/components/firefoxview/helpers.mjs @@ -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/. */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => { + return new Services.intl.RelativeTimeFormat(undefined, { style: "narrow" }); +}); + +// Cutoff of 1.5 minutes + 1 second to determine what text string to display +export const NOW_THRESHOLD_MS = 91000; + +export function formatURIForDisplay(uriString) { + return lazy.BrowserUtils.formatURIStringForDisplay(uriString); +} + +export function convertTimestamp( + timestamp, + fluentStrings, + _nowThresholdMs = NOW_THRESHOLD_MS +) { + if (!timestamp) { + // It's marginally better to show nothing instead of "53 years ago" + return ""; + } + const elapsed = Date.now() - timestamp; + let formattedTime; + if (elapsed <= _nowThresholdMs) { + // Use a different string for very recent timestamps + formattedTime = fluentStrings.formatValueSync( + "firefoxview-just-now-timestamp" + ); + } else { + formattedTime = lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp)); + } + return formattedTime; +} + +export function createFaviconElement(image, targetURI = "") { + const imageUrl = image + ? lazy.PlacesUIUtils.getImageURL(image) + : `page-icon:${targetURI}`; + let favicon = document.createElement("div"); + + favicon.style.backgroundImage = `url('${imageUrl}')`; + favicon.classList.add("favicon"); + return favicon; +} + +export function onToggleContainer(detailsContainer) { + // Ignore early `toggle` events, which may either be fired because the + // UI sections update visibility on component connected (based on persisted + // UI state), or because
elements fire `toggle` events when added + // to the DOM with the "open" attribute set. In either case, we don't want + // to record telemetry as these events aren't the result of user action. + if (detailsContainer.ownerDocument.readyState != "complete") { + return; + } + + const isOpen = detailsContainer.open; + const isTabPickup = detailsContainer.id === "tab-pickup-container"; + + const newFluentString = isOpen + ? "firefoxview-collapse-button-hide" + : "firefoxview-collapse-button-show"; + + detailsContainer + .querySelector(".twisty") + .setAttribute("data-l10n-id", newFluentString); + + if (isTabPickup) { + Services.telemetry.recordEvent( + "firefoxview", + "tab_pickup_open", + "tabs", + isOpen.toString() + ); + Services.prefs.setBoolPref( + "browser.tabs.firefox-view.ui-state.tab-pickup.open", + isOpen + ); + } else { + Services.telemetry.recordEvent( + "firefoxview", + "closed_tabs_open", + "tabs", + isOpen.toString() + ); + Services.prefs.setBoolPref( + "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open", + isOpen + ); + } +} diff --git a/browser/components/firefoxview/jar.mn b/browser/components/firefoxview/jar.mn new file mode 100644 index 0000000000..72d1fe8d87 --- /dev/null +++ b/browser/components/firefoxview/jar.mn @@ -0,0 +1,21 @@ +# 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/firefoxview.html + content/browser/firefoxview.mjs + content/browser/firefoxview.css + content/browser/helpers.mjs + content/browser/tab-pickup-container.mjs + content/browser/tab-pickup-list.mjs + content/browser/recently-closed-tabs.mjs + content/browser/colorways-card.mjs + content/browser/callout-tab-pickup.svg (content/callout-tab-pickup.svg) + content/browser/callout-colorways.svg (content/callout-colorways.svg) + content/browser/callout-tab-pickup-dark.svg (content/callout-tab-pickup-dark.svg) + content/browser/callout-colorways-dark.svg (content/callout-colorways-dark.svg) + content/browser/cfr-lightning.svg (content/cfr-lightning.svg) + content/browser/cfr-lightning-dark.svg (content/cfr-lightning-dark.svg) + content/browser/recently-closed-empty.svg (content/recently-closed-empty.svg) + content/browser/tab-pickup-empty.svg (content/tab-pickup-empty.svg) diff --git a/browser/components/firefoxview/moz.build b/browser/components/firefoxview/moz.build new file mode 100644 index 0000000000..381992ef02 --- /dev/null +++ b/browser/components/firefoxview/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Firefox View") + +EXTRA_JS_MODULES += [ + "*.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] diff --git a/browser/components/firefoxview/recently-closed-tabs.mjs b/browser/components/firefoxview/recently-closed-tabs.mjs new file mode 100644 index 0000000000..e3a207fc5d --- /dev/null +++ b/browser/components/firefoxview/recently-closed-tabs.mjs @@ -0,0 +1,488 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +import { + formatURIForDisplay, + convertTimestamp, + createFaviconElement, + onToggleContainer, + NOW_THRESHOLD_MS, +} from "./helpers.mjs"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; +const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush"; +const UI_OPEN_STATE = + "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open"; + +function getWindow() { + return window.browsingContext.embedderWindowGlobal.browsingContext.window; +} + +class RecentlyClosedTabsList extends HTMLElement { + constructor() { + super(); + this.maxTabsLength = 25; + this.closedTabsData = new Map(); + + // The recency timestamp update period is stored in a pref to allow tests to easily change it + XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + () => this.updateTime() + ); + } + + get tabsList() { + return this.querySelector("ol"); + } + + get fluentStrings() { + if (!this._fluentStrings) { + this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true); + } + return this._fluentStrings; + } + + get timeElements() { + return this.querySelectorAll("span.closed-tab-li-time"); + } + + connectedCallback() { + this.addEventListener("click", this); + this.addEventListener("keydown", this); + this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref); + } + + disconnectedCallback() { + clearInterval(this.intervalID); + } + + handleEvent(event) { + if ( + (event.type == "click" && !event.altKey) || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN) || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_SPACE) + ) { + if (!event.target.classList.contains("closed-tab-li-dismiss")) { + this.openTabAndUpdate(event); + } else { + this.dismissTabAndUpdate(event); + } + } + } + + updateTime() { + for (let timeEl of this.timeElements) { + timeEl.textContent = convertTimestamp( + parseInt(timeEl.getAttribute("data-timestamp")), + this.fluentStrings, + lazy.timeMsPref + ); + } + } + + getTabStateValue(tab, key) { + let value = ""; + const tabEntries = tab.state.entries; + const activeIndex = tab.state.index - 1; + + if (activeIndex >= 0 && tabEntries[activeIndex]) { + value = tabEntries[activeIndex][key]; + } + + return value; + } + + focusFirstItemOrHeader(dismissedIndex) { + // When a tab is removed from the list, the focus should + // remain on the list or the list header. This prevents context + // switching when navigating back to Firefox View. + let recentlyClosedList = [...this.tabsList.children]; + if (recentlyClosedList.length) { + recentlyClosedList.forEach(element => + element.setAttribute("tabindex", "-1") + ); + let mainContent; + if (dismissedIndex) { + // Select the item above the one that was just dismissed + mainContent = recentlyClosedList[dismissedIndex - 1].querySelector( + ".closed-tab-li-main" + ); + } else { + mainContent = recentlyClosedList[0].querySelector( + ".closed-tab-li-main" + ); + } + mainContent.setAttribute("tabindex", "0"); + mainContent.focus(); + } else { + document.getElementById("recently-closed-tabs-header-section").focus(); + } + } + + openTabAndUpdate(event) { + event.preventDefault(); + const item = event.target.closest(".closed-tab-li"); + // only used for telemetry + const position = [...this.tabsList.children].indexOf(item) + 1; + const closedId = item.dataset.tabid; + + lazy.SessionStore.undoCloseById(closedId); + this.tabsList.removeChild(item); + + this.focusFirstItemOrHeader(); + + // record telemetry + let tabClosedAt = parseInt( + item.querySelector(".closed-tab-li-time").getAttribute("data-timestamp") + ); + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview", + "recently_closed", + "tabs", + null, + { + position: position.toString(), + delta: deltaSeconds.toString(), + } + ); + } + + dismissTabAndUpdate(event) { + event.preventDefault(); + const item = event.target.closest(".closed-tab-li"); + let recentlyClosedList = lazy.SessionStore.getClosedTabData(getWindow()); + let closedTabIndex = recentlyClosedList.findIndex(closedTab => { + return closedTab.closedId === parseInt(item.dataset.tabid, 10); + }); + if (closedTabIndex < 0) { + // Tab not found in recently closed list + return; + } + this.tabsList.removeChild(item); + lazy.SessionStore.forgetClosedTab(getWindow(), closedTabIndex); + + this.focusFirstItemOrHeader(closedTabIndex); + + // record telemetry + let tabClosedAt = parseInt( + item.querySelector(".closed-tab-li-time").dataset.timestamp + ); + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview", + "dismiss_closed_tab", + "tabs", + null, + { + delta: deltaSeconds.toString(), + } + ); + } + + updateTabsList() { + let newClosedTabs = lazy.SessionStore.getClosedTabData(getWindow()); + newClosedTabs = newClosedTabs.slice(0, this.maxTabsLength); + + if (this.closedTabsData.size && !newClosedTabs.length) { + // if a user purges history, clear the list + while (this.tabsList.lastElementChild) { + this.tabsList.lastElementChild.remove(); + } + document + .getElementById("recently-closed-tabs-container") + .togglePlaceholderVisibility(true); + this.tabsList.hidden = true; + this.closedTabsData = new Map(); + return; + } + + // First purge obsolete items out of the map so we don't leak them forever: + for (let id of this.closedTabsData.keys()) { + if (!newClosedTabs.some(t => t.closedId == id)) { + this.closedTabsData.delete(id); + } + } + + // Then work out which of the new closed tabs are additions and which update + // existing items: + let tabsToAdd = []; + let tabsToUpdate = []; + for (let newTab of newClosedTabs) { + let oldTab = this.closedTabsData.get(newTab.closedId); + this.closedTabsData.set(newTab.closedId, newTab); + if (!oldTab) { + tabsToAdd.push(newTab); + } else if ( + this.getTabStateValue(oldTab, "url") != + this.getTabStateValue(newTab, "url") + ) { + tabsToUpdate.push(newTab); + } + } + + // Remove existing tabs from tabsList if not in latest closedTabsData + // which is necessary when using "Reopen Closed Tab" from the toolbar + // or when selecting "Forget this site" in History + [...this.tabsList.children].forEach(existingTab => { + if (!this.closedTabsData.get(parseInt(existingTab.dataset.tabid, 10))) { + this.tabsList.removeChild(existingTab); + } + }); + + // If there's nothing to add/update, return. + if (!tabsToAdd.length && !tabsToUpdate.length) { + return; + } + + // Add new tabs. + for (let tab of tabsToAdd.reverse()) { + if (this.tabsList.children.length == this.maxTabsLength) { + this.tabsList.lastChild.remove(); + } + let li = this.generateListItem(tab); + let mainContent = li.querySelector(".closed-tab-li-main"); + // Only the first item in the list should be focusable + if (!this.tabsList.children.length) { + mainContent.setAttribute("tabindex", "0"); + } else if (this.tabsList.children.length) { + mainContent.setAttribute("tabindex", "0"); + this.tabsList.children[0].setAttribute("tabindex", "-1"); + } + this.tabsList.prepend(li); + } + + // Update any recently closed tabs that now have different URLs: + for (let tab of tabsToUpdate) { + let tabElement = this.querySelector( + `.closed-tab-li[data-tabid="${tab.closedId}"]` + ); + let url = this.getTabStateValue(tab, "url"); + this.updateURLForListItem(tabElement, url); + } + + // Now unhide the list if necessary: + if (this.tabsList.hidden) { + this.tabsList.hidden = false; + document + .getElementById("recently-closed-tabs-container") + .togglePlaceholderVisibility(false); + } + } + + generateListItem(tab) { + const li = document.createElement("li"); + li.classList.add("closed-tab-li"); + li.dataset.tabid = tab.closedId; + + const title = document.createElement("span"); + title.textContent = `${tab.title}`; + title.classList.add("closed-tab-li-title"); + + const targetURI = this.getTabStateValue(tab, "url"); + const image = tab.image; + const favicon = createFaviconElement(image, targetURI); + + const urlElement = document.createElement("span"); + urlElement.classList.add("closed-tab-li-url"); + + const time = document.createElement("span"); + const convertedTime = convertTimestamp(tab.closedAt, this.fluentStrings); + time.textContent = convertedTime; + time.setAttribute("data-timestamp", tab.closedAt); + time.classList.add("closed-tab-li-time"); + + const mainContent = document.createElement("span"); + mainContent.classList.add("closed-tab-li-main"); + mainContent.setAttribute("role", "link"); + mainContent.setAttribute("tabindex", 0); + mainContent.append(favicon, title, urlElement, time); + + const dismissButton = document.createElement("button"); + let tabTitle = tab.title ?? ""; + document.l10n.setAttributes( + dismissButton, + "firefoxview-closed-tabs-dismiss-tab", + { + tabTitle, + } + ); + dismissButton.classList.add("closed-tab-li-dismiss"); + + li.append(mainContent, dismissButton); + this.updateURLForListItem(li, targetURI); + return li; + } + + // Update the URL for a new or previously-populated list item. + // This is needed because when tabs get closed we don't necessarily + // have all the requisite information for them immediately. + updateURLForListItem(li, targetURI) { + li.dataset.targetURI = targetURI; + let urlElement = li.querySelector(".closed-tab-li-url"); + document.l10n.setAttributes( + urlElement, + "firefoxview-tabs-list-tab-button", + { + targetURI, + } + ); + if (targetURI) { + urlElement.textContent = formatURIForDisplay(targetURI); + urlElement.title = targetURI; + } else { + urlElement.textContent = urlElement.title = ""; + } + } +} +customElements.define("recently-closed-tabs-list", RecentlyClosedTabsList); + +class RecentlyClosedTabsContainer extends HTMLDetailsElement { + constructor() { + super(); + this.observerAdded = false; + this.boundObserve = (...args) => this.observe(...args); + } + + connectedCallback() { + this.noTabsElement = this.querySelector( + "#recently-closed-tabs-placeholder" + ); + this.list = this.querySelector("recently-closed-tabs-list"); + this.collapsibleContainer = this.querySelector( + "#collapsible-tabs-container" + ); + this.addEventListener("toggle", this); + getWindow().gBrowser.tabContainer.addEventListener("TabSelect", this); + this.open = Services.prefs.getBoolPref(UI_OPEN_STATE, true); + } + + cleanup() { + getWindow().gBrowser.tabContainer.removeEventListener("TabSelect", this); + this.removeObserversIfNeeded(); + } + + addObserversIfNeeded() { + if (!this.observerAdded) { + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + this.observerAdded = true; + } + } + + removeObserversIfNeeded() { + if (this.observerAdded) { + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + this.observerAdded = false; + } + } + + // we observe when a tab closes but since this notification fires more frequently and on + // all windows, we remove the observer when another tab is selected; we check for changes + // to the session store once the user return to this tab. + handleObservers(contentDocument) { + if (contentDocument?.URL == "about:firefoxview") { + this.addObserversIfNeeded(); + this.list.updateTabsList(); + this.maybeUpdateFocus(); + } else { + this.removeObserversIfNeeded(); + } + } + + observe(subject, topic, data) { + if ( + topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED || + (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH && + subject.ownerGlobal == getWindow()) + ) { + this.list.updateTabsList(); + } + } + + onLoad() { + if (this.getClosedTabCount() == 0) { + this.togglePlaceholderVisibility(true); + } else { + this.list.updateTabsList(); + } + this.addObserversIfNeeded(); + } + + handleEvent(event) { + if (event.type == "toggle") { + onToggleContainer(this); + } else if (event.type == "TabSelect") { + this.handleObservers(event.target.linkedBrowser.contentDocument); + } + } + + /** + * Manages focus when returning to the Firefox View tab + * + * @memberof RecentlyClosedTabsContainer + */ + maybeUpdateFocus() { + // Check if focus is in the container element + if (this.contains(document.activeElement)) { + let listItems = this.list.querySelectorAll("li"); + // More tabs may have been added to the list, so we'll refocus + // the first item in the list. + if (listItems.length) { + listItems[0].querySelector(".closed-tab-li-main").focus(); + } else { + this.querySelector("summary").focus(); + } + } + } + + togglePlaceholderVisibility(visible) { + this.noTabsElement.toggleAttribute("hidden", !visible); + this.collapsibleContainer.classList.toggle("empty-container", visible); + } + + getClosedTabCount = () => { + try { + return lazy.SessionStore.getClosedTabCount(getWindow()); + } catch (ex) { + return 0; + } + }; +} +customElements.define( + "recently-closed-tabs-container", + RecentlyClosedTabsContainer, + { + extends: "details", + } +); diff --git a/browser/components/firefoxview/tab-pickup-container.mjs b/browser/components/firefoxview/tab-pickup-container.mjs new file mode 100644 index 0000000000..f554298a2a --- /dev/null +++ b/browser/components/firefoxview/tab-pickup-container.mjs @@ -0,0 +1,323 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env mozilla/remote-page */ + +import { onToggleContainer } from "./helpers.mjs"; + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; +const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +class TabPickupContainer extends HTMLDetailsElement { + constructor() { + super(); + this.boundObserve = (...args) => this.observe(...args); + this._currentSetupStateIndex = -1; + this.errorState = null; + this.tabListAdded = null; + } + get setupContainerElem() { + return this.querySelector(".sync-setup-container"); + } + + get tabsContainerElem() { + return this.querySelector(".synced-tabs-container"); + } + + get tabPickupListElem() { + return this.querySelector(".synced-tabs-container tab-pickup-list"); + } + + getWindow() { + return this.ownerGlobal.browsingContext.embedderWindowGlobal.browsingContext + .window; + } + + connectedCallback() { + this.addEventListener("click", this); + this.addEventListener("toggle", this); + this.addEventListener("visibilitychange", this); + Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + + for (let elem of this.querySelectorAll("a[data-support-url]")) { + elem.href = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + elem.dataset.supportUrl; + } + + // we wait until the list shows up before trying to populate it, + // when its safe to assume the custom-element's methods will be available + this.tabListAdded = this.promiseChildAdded(); + this.update(); + } + + promiseChildAdded() { + return new Promise(resolve => { + if (typeof this.tabPickupListElem?.getSyncedTabData == "function") { + resolve(); + return; + } + this.addEventListener( + "list-ready", + event => { + resolve(); + }, + { once: true } + ); + }); + } + + cleanup() { + Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + } + + disconnectedCallback() { + this.cleanup(); + } + + handleEvent(event) { + if (event.type == "toggle") { + onToggleContainer(this); + return; + } + if (event.type == "click" && event.target.dataset.action) { + switch (event.target.dataset.action) { + case "view0-sync-error-action": + case "view0-network-offline-action": + case "view0-password-locked-action": { + TabsSetupFlowManager.tryToClearError(); + break; + } + case "view0-signed-out-action": + case "view1-primary-action": { + TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal); + break; + } + case "view2-primary-action": + case "mobile-promo-primary-action": { + TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); + break; + } + case "view3-primary-action": { + TabsSetupFlowManager.syncOpenTabs(event.target); + break; + } + case "mobile-promo-dismiss": { + TabsSetupFlowManager.dismissMobilePromo(event.target); + break; + } + case "mobile-confirmation-dismiss": { + TabsSetupFlowManager.dismissMobileConfirmation(event.target); + break; + } + case "view0-sync-disconnected-action": { + const window = event.target.ownerGlobal; + const { + switchToTabHavingURI, + } = window.docShell.chromeEventHandler.ownerGlobal; + switchToTabHavingURI( + "about:preferences?action=choose-what-to-sync#sync", + true, + {} + ); + break; + } + } + } + // Returning to fxview seems like a likely time for a device check + if ( + event.type == "visibilitychange" && + document.visibilityState === "visible" + ) { + this.update(); + } + } + + async observe(subject, topic, errorState) { + if (topic == TOPIC_SETUPSTATE_CHANGED) { + this.update({ errorState }); + } + } + + get mobilePromoElem() { + return this.querySelector(".promo-box"); + } + get mobileSuccessElem() { + return this.querySelector(".confirmation-message-box"); + } + + update({ + stateIndex = TabsSetupFlowManager.uiStateIndex, + showMobilePromo = TabsSetupFlowManager.shouldShowMobilePromo, + showMobilePairSuccess = TabsSetupFlowManager.shouldShowMobileConnectedSuccess, + errorState = TabsSetupFlowManager.getErrorType(), + waitingForTabs = TabsSetupFlowManager.waitingForTabs, + } = {}) { + let needsRender = false; + if (waitingForTabs !== this._waitingForTabs) { + this._waitingForTabs = waitingForTabs; + needsRender = true; + } + + if (showMobilePromo !== this._showMobilePromo) { + this._showMobilePromo = showMobilePromo; + needsRender = true; + } + if (showMobilePairSuccess !== this._showMobilePairSuccess) { + this._showMobilePairSuccess = showMobilePairSuccess; + needsRender = true; + } + if (stateIndex == 4 && this._currentSetupStateIndex !== stateIndex) { + // trigger an initial request for the synced tabs list + this.tabListAdded.then(() => { + this.tabPickupListElem.getSyncedTabData(); + }); + } + if (stateIndex !== this._currentSetupStateIndex || stateIndex == 0) { + this._currentSetupStateIndex = stateIndex; + needsRender = true; + this.errorState = errorState; + } + needsRender && this.render(); + } + + generateErrorMessage() { + // We map the error state strings to Fluent string IDs so that it's easier + // to change strings in the future without having to update all of the + // error state strings. + const errorStateStringMappings = { + "sync-error": { + header: "firefoxview-tabpickup-sync-error-header", + description: "firefoxview-tabpickup-generic-sync-error-description", + buttonLabel: "firefoxview-tabpickup-sync-error-primarybutton", + }, + + "fxa-admin-disabled": { + header: "firefoxview-tabpickup-fxa-admin-disabled-header", + description: "firefoxview-tabpickup-fxa-admin-disabled-description", + // The button is hidden for this errorState, so we don't include the + // buttonLabel property. + }, + + "network-offline": { + header: "firefoxview-tabpickup-network-offline-header", + description: "firefoxview-tabpickup-network-offline-description", + buttonLabel: "firefoxview-tabpickup-network-offline-primarybutton", + }, + + "sync-disconnected": { + header: "firefoxview-tabpickup-sync-disconnected-header", + description: "firefoxview-tabpickup-sync-disconnected-description", + buttonLabel: "firefoxview-tabpickup-sync-disconnected-primarybutton", + }, + + "password-locked": { + header: "firefoxview-tabpickup-password-locked-header", + description: "firefoxview-tabpickup-password-locked-description", + buttonLabel: "firefoxview-tabpickup-password-locked-primarybutton", + link: { + label: "firefoxview-tabpickup-password-locked-link", + href: + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "primary-password-stored-logins", + }, + }, + "signed-out": { + header: "firefoxview-tabpickup-signed-out-header", + description: "firefoxview-tabpickup-signed-out-description", + buttonLabel: "firefoxview-tabpickup-signed-out-primarybutton", + }, + }; + + const errorStateHeader = this.querySelector( + "#tabpickup-steps-view0-header" + ); + const errorStateDescription = this.querySelector( + "#error-state-description" + ); + const errorStateButton = this.querySelector("#error-state-button"); + const errorStateLink = this.querySelector("#error-state-link"); + const errorStateProperties = errorStateStringMappings[this.errorState]; + + document.l10n.setAttributes(errorStateHeader, errorStateProperties.header); + document.l10n.setAttributes( + errorStateDescription, + errorStateProperties.description + ); + + errorStateButton.hidden = this.errorState == "fxa-admin-disabled"; + + if (this.errorState != "fxa-admin-disabled") { + document.l10n.setAttributes( + errorStateButton, + errorStateProperties.buttonLabel + ); + errorStateButton.setAttribute( + "data-action", + `view0-${this.errorState}-action` + ); + } + + if (errorStateProperties.link) { + document.l10n.setAttributes( + errorStateLink, + errorStateProperties.link.label + ); + errorStateLink.href = errorStateProperties.link.href; + errorStateLink.hidden = false; + } else { + errorStateLink.hidden = true; + } + } + + render() { + if (!this.isConnected) { + return; + } + + let setupElem = this.setupContainerElem; + let tabsElem = this.tabsContainerElem; + let mobilePromoElem = this.mobilePromoElem; + let mobileSuccessElem = this.mobileSuccessElem; + + const stateIndex = this._currentSetupStateIndex; + const isLoading = this._waitingForTabs; + + mobilePromoElem.hidden = !this._showMobilePromo; + mobileSuccessElem.hidden = !this._showMobilePairSuccess; + + this.open = + !TabsSetupFlowManager.isTabSyncSetupComplete || + Services.prefs.getBoolPref(UI_OPEN_STATE, true); + + // show/hide either the setup or tab list containers, creating each as necessary + if (stateIndex < 4) { + tabsElem.hidden = true; + setupElem.hidden = false; + setupElem.selectedViewName = `sync-setup-view${stateIndex}`; + + if (stateIndex == 0 && this.errorState) { + this.generateErrorMessage(); + } + return; + } + + setupElem.hidden = true; + tabsElem.hidden = false; + tabsElem.classList.toggle("loading", isLoading); + } + + async onReload() { + await TabsSetupFlowManager.syncOnPageReload(); + } +} +customElements.define("tab-pickup-container", TabPickupContainer, { + extends: "details", +}); + +export { TabPickupContainer }; diff --git a/browser/components/firefoxview/tab-pickup-list.mjs b/browser/components/firefoxview/tab-pickup-list.mjs new file mode 100644 index 0000000000..6cc5a61ff6 --- /dev/null +++ b/browser/components/firefoxview/tab-pickup-list.mjs @@ -0,0 +1,348 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "SyncedTabs", + "resource://services-sync/SyncedTabs.jsm" +); + +import { + formatURIForDisplay, + convertTimestamp, + createFaviconElement, + NOW_THRESHOLD_MS, +} from "./helpers.mjs"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; + +class TabPickupList extends HTMLElement { + constructor() { + super(); + this.maxTabsLength = 3; + this.boundObserve = (...args) => { + this.getSyncedTabData(...args); + }; + + // The recency timestamp update period is stored in a pref to allow tests to easily change it + XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + () => this.updateTime() + ); + } + + get tabsList() { + return this.querySelector("ol"); + } + + get fluentStrings() { + if (!this._fluentStrings) { + this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true); + } + return this._fluentStrings; + } + + get timeElements() { + return this.querySelectorAll("span.synced-tab-li-time"); + } + + connectedCallback() { + this.placeholderContainer = document.getElementById( + "synced-tabs-placeholder" + ); + this.tabPickupContainer = document.getElementById( + "tabpickup-tabs-container" + ); + + this.addEventListener("click", this); + Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED); + + // inform ancestor elements our getSyncedTabData method is available to fetch data + this.dispatchEvent(new CustomEvent("list-ready", { bubbles: true })); + } + + handleEvent(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN) + ) { + const item = event.target.closest(".synced-tab-li"); + let index = [...this.tabsList.children].indexOf(item); + let deviceType = item.dataset.deviceType; + Services.telemetry.recordEvent( + "firefoxview", + "tab_pickup", + "tabs", + null, + { + position: (++index).toString(), + deviceType, + } + ); + } + if (event.type == "keydown") { + switch (event.key) { + case "ArrowRight": { + event.preventDefault(); + this.moveFocusToSecondElement(); + break; + } + case "ArrowLeft": { + event.preventDefault(); + this.moveFocusToFirstElement(); + break; + } + case "ArrowDown": { + event.preventDefault(); + this.moveFocusToNextElement(); + break; + } + case "ArrowUp": { + event.preventDefault(); + this.moveFocusToPreviousElement(); + break; + } + case "Tab": { + this.resetFocus(event); + } + } + } + } + + /** + * Handles removing and setting tabindex on elements + * while moving focus to the next element + * + * @param {HTMLElement} currentElement currently focused element + * @param {HTMLElement} nextElement element that should receive focus next + * @memberof TabPickupList + * @private + */ + #manageTabIndexAndFocus(currentElement, nextElement) { + currentElement.setAttribute("tabindex", "-1"); + nextElement.removeAttribute("tabindex"); + nextElement.focus(); + } + + moveFocusToFirstElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let firstElement = selectableElements[0]; + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + this.#manageTabIndexAndFocus(selectedElement, firstElement); + } + + moveFocusToSecondElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let secondElement = selectableElements[1]; + if (secondElement) { + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + this.#manageTabIndexAndFocus(selectedElement, secondElement); + } + } + + moveFocusToNextElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + let nextElement = + selectableElements.findIndex(elem => elem == selectedElement) + 1; + if (nextElement < selectableElements.length) { + this.#manageTabIndexAndFocus( + selectedElement, + selectableElements[nextElement] + ); + } + } + moveFocusToPreviousElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + let previousElement = + selectableElements.findIndex(elem => elem == selectedElement) - 1; + if (previousElement >= 0) { + this.#manageTabIndexAndFocus( + selectedElement, + selectableElements[previousElement] + ); + } + } + resetFocus(e) { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + selectedElement.setAttribute("tabindex", "-1"); + selectableElements[0].removeAttribute("tabindex"); + if (e.shiftKey) { + e.preventDefault(); + document + .getElementById("tab-pickup-container") + .querySelector("summary") + .focus(); + } + } + + cleanup() { + Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED); + clearInterval(this.intervalID); + } + + updateTime() { + for (let timeEl of this.timeElements) { + timeEl.textContent = convertTimestamp( + parseInt(timeEl.getAttribute("data-timestamp")), + this.fluentStrings, + lazy.timeMsPref + ); + } + } + + togglePlaceholderVisibility(visible) { + this.placeholderContainer.toggleAttribute("hidden", !visible); + this.placeholderContainer.classList.toggle("empty-container", visible); + } + + async getSyncedTabData() { + let tabs = await lazy.SyncedTabs.getRecentTabs(50); + + this.updateTabsList(tabs); + } + + updateTabsList(syncedTabs) { + // don't do anything while the loading state is active + + while (this.tabsList.firstChild) { + this.tabsList.firstChild.remove(); + } + + if (!syncedTabs.length) { + this.sendTabTelemetry(0); + this.togglePlaceholderVisibility(true); + this.tabsList.hidden = true; + return; + } + + for (let i = 0; i < this.maxTabsLength; i++) { + let li = null; + if (!syncedTabs[i]) { + li = this.generatePlaceholder(); + } else { + li = this.generateListItem(syncedTabs[i], i); + } + this.tabsList.append(li); + } + + if (this.tabsList.hidden) { + this.tabsList.hidden = false; + this.togglePlaceholderVisibility(false); + + if (!this.intervalID) { + this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref); + } + } + + this.sendTabTelemetry(syncedTabs.length); + } + + generatePlaceholder() { + const li = document.createElement("li"); + li.classList.add("synced-tab-li-placeholder"); + li.setAttribute("role", "presentation"); + + const favicon = document.createElement("span"); + favicon.classList.add("li-placeholder-favicon"); + + const title = document.createElement("span"); + title.classList.add("li-placeholder-title"); + + const domain = document.createElement("span"); + domain.classList.add("li-placeholder-domain"); + + li.append(favicon, title, domain); + return li; + } + + generateListItem(tab, index) { + const li = document.createElement("li"); + li.classList.add("synced-tab-li"); + li.dataset.deviceType = tab.deviceType; + + const targetURI = tab.url; + const a = document.createElement("a"); + a.classList.add("synced-tab-a"); + a.href = targetURI; + a.target = "_blank"; + if (index != 0) { + a.setAttribute("tabindex", "-1"); + } + a.addEventListener("keydown", this); + + const title = document.createElement("span"); + title.textContent = tab.title; + title.classList.add("synced-tab-li-title"); + + const favicon = createFaviconElement(tab.icon, targetURI); + + const lastUsedMs = tab.lastUsed * 1000; + const time = document.createElement("span"); + time.textContent = convertTimestamp(lastUsedMs, this.fluentStrings); + time.classList.add("synced-tab-li-time"); + time.setAttribute("data-timestamp", lastUsedMs); + + const url = document.createElement("span"); + const device = document.createElement("span"); + const deviceIcon = document.createElement("div"); + deviceIcon.classList.add("icon", tab.deviceType); + deviceIcon.setAttribute("role", "presentation"); + + const deviceText = tab.device; + device.textContent = deviceText; + device.prepend(deviceIcon); + device.title = deviceText; + + url.textContent = formatURIForDisplay(tab.url); + url.title = tab.url; + url.classList.add("synced-tab-li-url"); + document.l10n.setAttributes(url, "firefoxview-tabs-list-tab-button", { + targetURI, + }); + device.classList.add("synced-tab-li-device"); + + // the first list item is different from the second and third + if (index == 0) { + const badge = this.createBadge(); + a.append(favicon, badge, title, url, device, time); + } else { + a.append(favicon, title, url, device, time); + } + + li.append(a); + return li; + } + + createBadge() { + const badge = document.createElement("div"); + const dot = document.createElement("span"); + const badgeText = document.createElement("span"); + + badgeText.setAttribute("data-l10n-id", "firefoxview-pickup-tabs-badge"); + badgeText.classList.add("badge-text"); + badge.classList.add("last-active-badge"); + dot.classList.add("dot"); + badge.append(dot, badgeText); + return badge; + } + + sendTabTelemetry(numTabs) { + Services.telemetry.recordEvent("firefoxview", "synced_tabs", "tabs", null, { + count: numTabs.toString(), + }); + } +} + +customElements.define("tab-pickup-list", TabPickupList); diff --git a/browser/components/firefoxview/tests/browser/browser.ini b/browser/components/firefoxview/tests/browser/browser.ini new file mode 100644 index 0000000000..3e0507b747 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser.ini @@ -0,0 +1,33 @@ +[DEFAULT] +support-files = head.js +prefs = + browser.tabs.firefox-view.logLevel=All + +[browser_dragDrop_after_opening_fxViewTab.js] +[browser_entrypoint_management.js] +[browser_firefoxview.js] +[browser_firefoxview_accessibility.js] +[browser_firefoxview_feature_callout_a11y.js] +[browser_firefoxview_tab.js] +[browser_keyboard_focus.js] +[browser_media_query_dom_sorting.js] +[browser_notification_dot.js] +[browser_recently_closed_tabs.js] +[browser_recently_closed_tabs_keyboard.js] +[browser_reload_firefoxview.js] +[browser_setup_errors.js] +[browser_setup_primary_password.js] +[browser_setup_state.js] +[browser_setup_synced_tabs_loading.js] +[browser_sync_admin_disabled.js] +[browser_tab_pickup_list.js] +[browser_colorways_card.js] +[browser_cfr_message.js] +skip-if = true # Bug 1783684 +[browser_feature_callout.js] +[browser_feature_callout_position.js] +[browser_feature_callout_resize.js] +[browser_feature_callout_targeting.js] +[browser_tab_close_last_tab.js] +[browser_tab_on_close_warning.js] +[browser_ui_state.js] diff --git a/browser/components/firefoxview/tests/browser/browser_cfr_message.js b/browser/components/firefoxview/tests/browser/browser_cfr_message.js new file mode 100644 index 0000000000..ee9df2f105 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_cfr_message.js @@ -0,0 +1,67 @@ +const { ASRouter } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); +const { ASRouterTriggerListeners } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouterTriggerListeners.jsm" +); + +const { SpecialMessageActions } = ChromeUtils.import( + "resource://messaging-system/lib/SpecialMessageActions.jsm" +); + +add_task(async function cfr_firefoxview_should_show() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 0]], + }); + + let cfrSpy = sinon.spy(ASRouter, "routeCFRMessage"); + let specialMessageActionsSpy = sinon.spy( + SpecialMessageActions, + "handleAction" + ); + registerCleanupFunction(() => { + cfrSpy.restore(); + specialMessageActionsSpy.restore(); + ASRouter.resetMessageState(); + ASRouter.unblockMessageById("CFR_FIREFOX_VIEW"); + ASRouterTriggerListeners.get("nthTabClosed").uninit(); + }); + + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown", + target => { + return target; + } + ); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); + + await showPanel; + + Assert.equal(cfrSpy.lastCall.args[0].id, "CFR_FIREFOX_VIEW"); + + const notification = document.querySelector( + "#contextual-feature-recommendation-notification" + ); + + Assert.ok(notification); + Assert.ok(document.querySelector(".popup-notification-primary-button")); + + Assert.ok(document.querySelector(".popup-notification-secondary-button")); + + await notification.button.click(); + + Assert.equal( + specialMessageActionsSpy.firstCall.args[0].type, + "OPEN_FIREFOX_VIEW" + ); + await SpecialPowers.popPrefEnv(); + + closeFirefoxViewTab(window); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_colorways_card.js b/browser/components/firefoxview/tests/browser/browser_colorways_card.js new file mode 100644 index 0000000000..48723905ff --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_colorways_card.js @@ -0,0 +1,443 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { AddonTestUtils } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); + +const TEST_COLLECTION_FIGURE_URL = "https://www.example.com/collection.avif"; +const TEST_COLORWAY_FIGURE_URL = "https://www.example.com/colorway.avif"; + +const TEST_COLORWAY_COLLECTION = { + id: "independent-voices", + expiry: new Date("3000-01-01"), + l10nId: { + title: "colorway-collection-independent-voices", + description: "colorway-collection-independent-voices-description", + }, + figureUrl: TEST_COLLECTION_FIGURE_URL, +}; + +const SOFT_COLORWAY_THEME_ID = "mocktheme-soft-colorway@mozilla.org"; +const BALANCED_COLORWAY_THEME_ID = "mocktheme-balanced-colorway@mozilla.org"; +const BOLD_COLORWAY_THEME_ID = "mocktheme-bold-colorway@mozilla.org"; +const NO_INTENSITY_COLORWAY_THEME_ID = "mocktheme-colorway@mozilla.org"; +const OUTDATED_COLORWAY_THEME_ID = "outdatedtheme-colorway@mozilla.org"; + +const EXPIRY_DATE_L10N_ID = "colorway-collection-expiry-label"; +const COLORWAY_DESCRIPTION_L10N_ID = "firefoxview-colorway-description"; +const MOCK_THEME_L10N_VALUE = "Mock Theme"; +const SOFT_L10N_VALUE = "Soft"; + +const TRY_COLORWAYS_EVENT = [ + ["colorways_modal", "try_colorways", "firefoxview", undefined], +]; + +const CHANGE_COLORWAY_EVENT = [ + ["colorways_modal", "change_colorway", "firefoxview", undefined], +]; + +function getTestElements(document) { + return { + container: document.getElementById("colorways"), + title: document.getElementById("colorways-collection-title"), + description: document.getElementById("colorways-collection-description"), + expiryPill: document.querySelector("#colorways-collection-expiry-date"), + expiry: document.querySelector("#colorways-collection-expiry-date > span"), + figure: document.getElementById("colorways-collection-figure"), + }; +} + +async function createTempTheme(id) { + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + name: "Monochromatic Theme", + browser_specific_settings: { gecko: { id } }, + theme: {}, + }, + }); + return AddonTestUtils.promiseInstallFile(xpi); +} + +let gCollectionEnabled = true; + +// TODO: use Colorway Closet mocks and helper functions (Bug 1783675) + +add_setup(async function setup_tests() { + const sandbox = sinon.createSandbox(); + sandbox + .stub(BuiltInThemes, "findActiveColorwayCollection") + .callsFake(() => (gCollectionEnabled ? TEST_COLORWAY_COLLECTION : null)); + sandbox + .stub(BuiltInThemes, "isColorwayFromCurrentCollection") + .callsFake( + id => + id === SOFT_COLORWAY_THEME_ID || + id === BALANCED_COLORWAY_THEME_ID || + id === BOLD_COLORWAY_THEME_ID || + id === NO_INTENSITY_COLORWAY_THEME_ID + ); + sandbox + .stub(BuiltInThemes, "getLocalizedColorwayGroupName") + .returns(MOCK_THEME_L10N_VALUE); + sandbox.stub(BuiltInThemes.builtInThemeMap, "get").returns({ + figureUrl: TEST_COLORWAY_FIGURE_URL, + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.theme.colorway-closet", true]], + }); + const tempThemes = await Promise.all( + [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + NO_INTENSITY_COLORWAY_THEME_ID, + OUTDATED_COLORWAY_THEME_ID, + ].map(createTempTheme) + ); + registerCleanupFunction(async () => { + sandbox.restore(); + await SpecialPowers.popPrefEnv(); + for (const { addon } of tempThemes) { + await addon.disable(); + await addon.uninstall(true); + } + }); +}); + +add_task(async function no_collection_test() { + gCollectionEnabled = false; + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const { container } = getTestElements(document); + ok(!BrowserTestUtils.is_visible(container), "Colorways card is hidden"); + }); + } finally { + gCollectionEnabled = true; + } +}); + +add_task(async function colorway_closet_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.theme.colorway-closet", false]], + }); + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const { container } = getTestElements(document); + ok( + !BrowserTestUtils.is_visible(container), + "Colorways card is hidden when Colorway Closet is disabled" + ); + }); + } finally { + await SpecialPowers.popPrefEnv(); + } +}); + +add_task(async function no_active_colorway_test() { + // Set to default theme to unapply any enabled colorways + const theme = await AddonManager.getAddonByID("default-theme@mozilla.org"); + await theme.enable(); + try { + await clearAllParentTelemetryEvents(); + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLLECTION_FIGURE_URL, + "Collection figure should be shown" + ); + is( + document.l10n.getAttributes(el.title).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.description, + "Collection description should be shown" + ); + ok(!el.expiryPill.hidden, "Expiry pill is shown"); + const expiryL10nAttributes = document.l10n.getAttributes(el.expiry); + is( + expiryL10nAttributes.args.expiryDate, + TEST_COLORWAY_COLLECTION.expiry.getTime(), + "Correct expiry date should be shown" + ); + is( + expiryL10nAttributes.id, + EXPIRY_DATE_L10N_ID, + "Correct expiry date format should be shown" + ); + + document.querySelector("#colorways-button").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + let colorwayEvents = events.filter(e => e[1] === "colorways_modal"); + return colorwayEvents && colorwayEvents.length; + }, + "Waiting for try_colorways colorways telemetry event.", + 200, + 100 + ); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + let colorwayEvents = events.filter(e => e[1] === "colorways_modal"); + + info(JSON.stringify(colorwayEvents)); + + TelemetryTestUtils.assertEvents( + TRY_COLORWAYS_EVENT, + { category: "colorways_modal" }, + { clear: true, process: "parent" } + ); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function active_colorway_test() { + const theme = await AddonManager.getAddonByID(SOFT_COLORWAY_THEME_ID); + await theme.enable(); + try { + await clearAllParentTelemetryEvents(); + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLORWAY_FIGURE_URL, + "Colorway figure should be shown" + ); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + const descriptionL10nAttributes = document.l10n.getAttributes( + el.description + ); + is( + descriptionL10nAttributes.id, + COLORWAY_DESCRIPTION_L10N_ID, + "Colorway description should be shown" + ); + is( + descriptionL10nAttributes.args.intensity, + SOFT_L10N_VALUE, + "Colorway intensity should be shown" + ); + is( + descriptionL10nAttributes.args.collection, + "Independent Voices", + "Collection name should be shown" + ); + ok(el.expiryPill.hidden, "Expiry pill is hidden"); + + document.querySelector("#colorways-button").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + let colorwayEvents = events.filter(e => e[1] === "colorways_modal"); + return colorwayEvents && colorwayEvents.length; + }, + "Waiting for change_colorway colorways telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + CHANGE_COLORWAY_EVENT, + { category: "colorways_modal" }, + { clear: true, process: "parent" } + ); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function active_colorway_without_intensity_test() { + const theme = await AddonManager.getAddonByID(NO_INTENSITY_COLORWAY_THEME_ID); + await theme.enable(); + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLORWAY_FIGURE_URL, + "Colorway figure should be shown" + ); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection name should be shown as the description" + ); + ok(el.expiryPill.hidden, "Expiry pill is hidden"); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function active_colorway_is_outdated_test() { + const theme = await AddonManager.getAddonByID(OUTDATED_COLORWAY_THEME_ID); + await theme.enable(); + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Description should be visible" + ); + is( + el.figure.src, + TEST_COLLECTION_FIGURE_URL, + "Collection figure should be shown" + ); + is( + document.l10n.getAttributes(el.title).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.description, + "Collection description should be shown" + ); + ok(!el.expiryPill.hidden, "Expiry pill is shown"); + const expiryL10nAttributes = document.l10n.getAttributes(el.expiry); + is( + expiryL10nAttributes.args.expiryDate, + TEST_COLORWAY_COLLECTION.expiry.getTime(), + "Correct expiry date should be shown" + ); + is( + expiryL10nAttributes.id, + EXPIRY_DATE_L10N_ID, + "Correct expiry date format should be shown" + ); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function change_active_colorway_test() { + let theme = await AddonManager.getAddonByID(NO_INTENSITY_COLORWAY_THEME_ID); + await theme.enable(); + try { + await withFirefoxView({ win: window }, async browser => { + info("Start with no intensity theme"); + const { document } = browser.contentWindow; + let el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLORWAY_FIGURE_URL, + "Colorway figure should be shown" + ); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + info("Revert to default theme"); + await theme.disable(); + el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLLECTION_FIGURE_URL, + "Collection figure should be shown" + ); + is( + document.l10n.getAttributes(el.title).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.description, + "Collection description should be shown" + ); + info("Enable a different theme"); + theme = await AddonManager.getAddonByID(SOFT_COLORWAY_THEME_ID); + await theme.enable(); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + const descriptionL10nAttributes = document.l10n.getAttributes( + el.description + ); + is( + descriptionL10nAttributes.id, + COLORWAY_DESCRIPTION_L10N_ID, + "Colorway description should be shown" + ); + is( + descriptionL10nAttributes.args.intensity, + SOFT_L10N_VALUE, + "Colorway intensity should be shown" + ); + is( + descriptionL10nAttributes.args.collection, + "Independent Voices", + "Collection name should be shown" + ); + }); + } finally { + await theme.disable(); + } +}); diff --git a/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js new file mode 100644 index 0000000000..e2255fabbf --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that dragging and dropping tabs into tabbrowser works as intended + * after opening the Firefox View tab for RTL builds. There was an issue where + * tabs from dragged links were not dropped in the correct tab indexes + * for RTL builds because logic for RTL builds did not take into consideration + * hidden tabs like the Firefox View tab. This test makes sure that this behavior does not reoccur. + */ +add_task(async function() { + info("Setting browser to RTL locale"); + await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] }); + + // window.RTL_UI doesn't update in existing windows when this pref is changed, + // so we need to test in a new window. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + let newTab = win.gBrowser.tabs[0]; + + let waitForTestTabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + TEST_ROOT + "file_new_tab_page.html" + ); + let testTab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + TEST_ROOT + "file_new_tab_page.html" + ); + await waitForTestTabPromise; + + let linkSrcEl = win.document.querySelector("a"); + ok(linkSrcEl, "Link exists"); + + let dropPromise = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "drop" + ); + + /** + * There should be 2 tabs: + * 1. new tab (auto-generated) + * 2. test tab + */ + is(win.gBrowser.visibleTabs.length, 2, "There should be 2 tabs"); + + // Now open Firefox View tab + info("Opening Firefox View tab"); + await openFirefoxViewTab(win); + + /** + * There should be 2 visible tabs: + * 1. new tab (auto-generated) + * 2. test tab + * Firefox View tab is hidden. + */ + is( + win.gBrowser.visibleTabs.length, + 2, + "There should still be 2 visible tabs after opening Firefox View tab" + ); + + info("Switching to test tab"); + await BrowserTestUtils.switchTab(win.gBrowser, testTab); + + let waitForDraggedTabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + "https://example.com/#test" + ); + + info("Dragging link between test tab and new tab"); + EventUtils.synthesizeDrop( + linkSrcEl, + win.gBrowser.tabContainer, + [[{ type: "text/plain", data: "https://example.com/#test" }]], + "link", + win, + win, + { + clientX: testTab.getBoundingClientRect().right, + } + ); + + info("Waiting for drop event"); + await dropPromise; + info("Waiting for dragged tab to be created"); + let draggedTab = await waitForDraggedTabPromise; + + /** + * There should be 3 visible tabs: + * 1. new tab (auto-generated) + * 2. new tab from dragged link + * 3. test tab + * + * In RTL build, it should appear in the following order: + * | + */ + is(win.gBrowser.visibleTabs.length, 3, "There should be 3 tabs"); + is( + win.gBrowser.visibleTabs.indexOf(newTab), + 0, + "New tab should still be rightmost visible tab" + ); + is( + win.gBrowser.visibleTabs.indexOf(draggedTab), + 1, + "Dragged link should positioned at new index" + ); + is( + win.gBrowser.visibleTabs.indexOf(testTab), + 2, + "Test tab should be to the left of dragged tab" + ); + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js new file mode 100644 index 0000000000..ef6b0c99f5 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_removing_button_should_close_tab() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + let tab = browser.getTabBrowser().getTabForBrowser(browser); + let button = win.document.getElementById("firefox-view-button"); + await win.gCustomizeMode.removeFromArea(button, "toolbar-context-menu"); + ok(!tab.isConnected, "Tab should have been removed."); + isnot(win.gBrowser.selectedTab, tab, "A different tab should be selected."); + }); + CustomizableUI.reset(); +}); + +add_task(async function test_button_auto_readd() { + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + + CustomizableUI.removeWidgetFromArea("firefox-view-button"); + ok( + !CustomizableUI.getPlacementOfWidget("firefox-view-button"), + "Button has no placement" + ); + ok(!FirefoxViewHandler.tab, "Shouldn't have tab reference"); + ok(!FirefoxViewHandler.button, "Shouldn't have button reference"); + + FirefoxViewHandler.openTab(); + ok(FirefoxViewHandler.tab, "Tab re-opened"); + ok(FirefoxViewHandler.button, "Button re-added"); + let placement = CustomizableUI.getPlacementOfWidget("firefox-view-button"); + is( + placement.area, + CustomizableUI.AREA_TABSTRIP, + "Button re-added to the tabs toolbar" + ); + is(placement.position, 0, "Button re-added as the first toolbar element"); + }); + CustomizableUI.reset(); +}); + +add_task(async function test_button_moved() { + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + CustomizableUI.addWidgetToArea( + "firefox-view-button", + CustomizableUI.AREA_NAVBAR, + 0 + ); + is( + FirefoxViewHandler.button.closest("toolbar").id, + "nav-bar", + "Button is in the navigation toolbar" + ); + }); + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + is( + FirefoxViewHandler.button.closest("toolbar").id, + "nav-bar", + "Button remains in the navigation toolbar" + ); + }); + CustomizableUI.reset(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout.js b/browser/components/firefoxview/tests/browser/browser_feature_callout.js new file mode 100644 index 0000000000..e90b7ab7ac --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout.js @@ -0,0 +1,655 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { MessageLoaderUtils } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); + +const featureTourPref = "browser.firefox-view.feature-tour"; +const defaultPrefValue = getPrefValueByScreen(1); + +add_setup(async function() { + requestLongerTimeout(2); + registerCleanupFunction(() => ASRouter.resetMessageState()); +}); + +add_task(async function feature_callout_renders_in_firefox_view() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + } + ); +}); + +add_task(async function feature_callout_is_not_shown_twice() { + // Third comma-separated value of the pref is set to a string value once a user completes the tour + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, '{"message":"","screen":"","complete":true}']], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + ok( + !document.querySelector(calloutSelector), + "Feature Callout tour does not render if the user finished it previously" + ); + } + ); + Services.prefs.clearUserPref(featureTourPref); +}); + +add_task(async function feature_callout_syncs_across_visits_and_tabs() { + // Second comma-separated value of the pref is the id + // of the last viewed screen of the feature tour + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_1","complete":false}']], + }); + // Open an about:firefoxview tab + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:firefoxview" + ); + let tab1Doc = tab1.linkedBrowser.contentWindow.document; + await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_1"); + + ok( + tab1Doc.querySelector(".FEATURE_CALLOUT_1"), + "First tab's Feature Callout shows the tour screen saved in the user pref" + ); + + // Open a second about:firefoxview tab + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:firefoxview" + ); + let tab2Doc = tab2.linkedBrowser.contentWindow.document; + await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_1"); + + ok( + tab2Doc.querySelector(".FEATURE_CALLOUT_1"), + "Second tab's Feature Callout shows the tour screen saved in the user pref" + ); + + await clickPrimaryButton(tab2Doc); + + gBrowser.selectedTab = tab1; + tab1.focus(); + await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_2"); + ok( + tab1Doc.querySelector(".FEATURE_CALLOUT_2"), + "First tab's Feature Callout advances to the next screen when the tour is advanced in second tab" + ); + + await clickPrimaryButton(tab1Doc); + gBrowser.selectedTab = tab1; + await waitForCalloutRemoved(tab1Doc); + + ok( + !tab1Doc.body.querySelector(calloutSelector), + "Feature Callout is removed in first tab after being dismissed in first tab" + ); + + gBrowser.selectedTab = tab2; + tab2.focus(); + await waitForCalloutRemoved(tab2Doc); + + ok( + !tab2Doc.body.querySelector(calloutSelector), + "Feature Callout is removed in second tab after tour was dismissed in first tab" + ); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + Services.prefs.clearUserPref(featureTourPref); +}); + +add_task(async function feature_callout_closes_on_dismiss() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + const spy = new TelemetrySpy(sandbox); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + ok( + !document.querySelector(calloutSelector), + "Callout is removed from screen on dismiss" + ); + + let tourComplete = JSON.parse( + Services.prefs.getStringPref(featureTourPref) + ).complete; + ok( + tourComplete, + `Tour is recorded as complete in ${featureTourPref} preference value` + ); + + // Test that appropriate telemetry is sent + spy.assertCalledWith({ + event: "CLICK_BUTTON", + event_context: { + source: "dismiss_button", + page: document.location.href, + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: "dismiss_button", + page: document.location.href, + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_only_highlights_existing_elements() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].parent_selector = "#fake-selector"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + ok( + !document.querySelector(`${calloutSelector}:not(.hidden)`), + "Feature Callout screen does not render if its parent element does not exist" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_arrow_class_exists() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + const arrowParent = document.querySelector(".callout-arrow.arrow-top"); + ok(arrowParent, "Arrow class exists on parent container"); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_arrow_is_not_flipped_on_ltr() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "start"; + testMessage.message.content.screens[0].parent_selector = "span.brand-icon"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await BrowserTestUtils.waitForCondition(() => { + return document.querySelector( + `${calloutSelector}.arrow-inline-start:not(.hidden)` + ); + }); + ok( + true, + "Feature Callout arrow parent has arrow-start class when arrow direction is set to 'start'" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_respects_cfr_features_pref() { + async function toggleCFRFeaturesPref(value, extraPrefs = []) { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + value, + ], + ...extraPrefs, + ], + }); + } + + await toggleCFRFeaturesPref(true, [[featureTourPref, defaultPrefValue]]); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + + await toggleCFRFeaturesPref(false); + await waitForCalloutRemoved(document); + ok( + !document.querySelector(calloutSelector), + "Feature Callout element was removed because CFR pref was disabled" + ); + } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + ok( + !document.querySelector(calloutSelector), + "Feature Callout element was not created because CFR pref was disabled" + ); + + await toggleCFRFeaturesPref(true); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element was created because CFR pref was enabled" + ); + } + ); +}); + +add_task( + async function feature_callout_tab_pickup_reminder_primary_click_elm() { + Services.prefs.setBoolPref("identity.fxaccounts.enabled", false); + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + const expectedUrl = await fxAccounts.constructor.config.promiseConnectAccountURI( + "fx-view" + ); + info(`Expected FxA URL: ${expectedUrl}`); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + let tabOpened = new Promise(resolve => { + gBrowser.tabContainer.addEventListener( + "TabOpen", + event => { + let newTab = event.target; + let newBrowser = newTab.linkedBrowser; + let result = newTab; + BrowserTestUtils.waitForDocLoadAndStopIt( + expectedUrl, + newBrowser + ).then(() => resolve(result)); + }, + { once: true } + ); + }); + + info("Waiting for callout to render"); + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + + info("Clicking primary button"); + let calloutRemoved = waitForCalloutRemoved(document); + await clickPrimaryButton(document); + let openedTab = await tabOpened; + ok(openedTab, "FxA sign in page opened"); + // The callout should be removed when primary CTA is clicked + await calloutRemoved; + BrowserTestUtils.removeTab(openedTab); + } + ); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); + sandbox.restore(); + } +); + +add_task(async function feature_callout_dismiss_on_page_click() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, `{"message":"","screen":"","complete":true}`]], + }); + const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER"; + const testClickSelector = "#recently-closed-tabs-container"; + let testMessage = getCalloutMessageById(screenId); + // Configure message with a dismiss action on tab container click + testMessage.message.content.screens[0].content.page_event_listeners = [ + { + params: { + type: "click", + selectors: testClickSelector, + }, + action: { + dismiss: true, + }, + }, + ]; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + const spy = new TelemetrySpy(sandbox); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + info("Waiting for callout to render"); + await waitForCalloutScreen(document, screenId); + + info("Clicking page element"); + document.querySelector(testClickSelector).click(); + await waitForCalloutRemoved(document); + + // Test that appropriate telemetry is sent + spy.assertCalledWith({ + event: "PAGE_EVENT", + event_context: { + action: "DISMISS", + reason: "CLICK", + source: sinon.match(testClickSelector), + page: document.location.href, + }, + message_id: screenId, + }); + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: sinon + .match("PAGE_EVENT:") + .and(sinon.match(testClickSelector)), + page: document.location.href, + }, + message_id: screenId, + }); + + browser.tabDialogBox + ?.getTabDialogManager() + .dialogs.forEach(dialog => dialog.close()); + } + ); + Services.prefs.clearUserPref("browser.firefox-view.view-count"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +add_task(async function feature_callout_advance_tour_on_page_click() { + let sandbox = sinon.createSandbox(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + featureTourPref, + JSON.stringify({ + message: "FIREFOX_VIEW_FEATURE_TOUR", + screen: "FEATURE_CALLOUT_1", + complete: false, + }), + ], + ], + }); + + // Add page action listeners to the built-in messages. + const TEST_MESSAGES = FeatureCalloutMessages.getMessages().filter(msg => + [ + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS", + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS", + ].includes(msg.id) + ); + TEST_MESSAGES.forEach(msg => { + let { content } = msg.content.screens[msg.content.startScreen ?? 0]; + content.page_event_listeners = [ + { + params: { + type: "click", + selectors: ".brand-logo", + }, + action: JSON.parse(JSON.stringify(content.primary_button.action)), + }, + ]; + }); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + info("Clicking page button"); + document.querySelector(".brand-logo").click(); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + info("Clicking page button"); + document.querySelector(".brand-logo").click(); + + await waitForCalloutRemoved(document); + let tourComplete = JSON.parse( + Services.prefs.getStringPref(featureTourPref) + ).complete; + ok( + tourComplete, + `Tour is recorded as complete in ${featureTourPref} preference value` + ); + } + ); + + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); +}); + +add_task(async function test_firefox_view_spotlight_promo() { + // Prevent attempts to fetch CFR messages remotely. + const sandbox = sinon.createSandbox(); + let remoteSettingsStub = sandbox.stub( + MessageLoaderUtils, + "_remoteSettingsLoader" + ); + remoteSettingsStub.resolves([]); + + await SpecialPowers.pushPrefEnv({ + clear: [ + [featureTourPref], + ["browser.newtabpage.activity-stream.asrouter.providers.cfr"], + ], + }); + ASRouter.resetMessageState(); + + let dialogOpenPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + "chrome://browser/content/spotlight.html", + { isSubDialog: true } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + info("Waiting for the Fx View Spotlight promo to open"); + let dialogBrowser = await dialogOpenPromise; + let primaryBtnSelector = ".action-buttons button.primary"; + await TestUtils.waitForCondition( + () => dialogBrowser.document.querySelector("main.DEFAULT_MODAL_UI"), + `Should render main.DEFAULT_MODAL_UI` + ); + + dialogBrowser.document.querySelector(primaryBtnSelector).click(); + info("Fx View Spotlight promo clicked"); + + await BrowserTestUtils.waitForCondition( + () => + browser.contentWindow.performance.navigation.type == + browser.contentWindow.performance.navigation.TYPE_RELOAD + ); + info("Spotlight modal cleared, entering feature tour"); + + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + info("Feature tour started"); + await clickPrimaryButton(document); + } + ); + + ok(remoteSettingsStub.called, "Tried to load CFR messages"); + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +add_task(async function feature_callout_returns_default_fxview_focus_to_top() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + ok( + document.activeElement.localName === "body", + "by default focus returns to the document body after callout closes" + ); + } + ); + sandbox.restore(); +}); + +add_task( + async function feature_callout_returns_moved_fxview_focus_to_previous() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + + // change focus to recently-closed-tabs-container + let recentlyClosedHeaderSection = document.querySelector( + "#recently-closed-tabs-header-section" + ); + recentlyClosedHeaderSection.focus(); + + // close the callout dialog + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + // verify that the focus landed in the right place + ok( + document.activeElement.id === "recently-closed-tabs-header-section", + "when focus changes away from callout it reverts after callout closes" + ); + } + ); + sandbox.restore(); + } +); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js new file mode 100644 index 0000000000..8edee7c71b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js @@ -0,0 +1,403 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const featureTourPref = "browser.firefox-view.feature-tour"; +const defaultPrefValue = getPrefValueByScreen(1); + +add_task( + async function feature_callout_first_screen_positioned_below_element() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parentBottom = document + .querySelector("#tab-pickup-container") + .getBoundingClientRect().bottom; + let containerTop = document + .querySelector(calloutSelector) + .getBoundingClientRect().top; + + Assert.lessOrEqual( + parentBottom, + containerTop + 5 + 1, // Add 5px for overlap and 1px for fuzziness to account for possible subpixel rounding + "Feature Callout is positioned below parent element with 5px overlap" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_second_screen_positioned_left_of_element() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + testMessage.message.content.screens[1].content.arrow_position = "end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + const parent = document.querySelector( + "#recently-closed-tabs-container" + ); + parent.style.gridArea = "1/2"; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentLeft = parent.getBoundingClientRect().left; + let containerRight = document + .querySelector(calloutSelector) + .getBoundingClientRect().right; + + Assert.greaterOrEqual( + parentLeft, + containerRight - 5 - 1, // Subtract 5px for overlap and 1px for fuzziness to account for possible subpixel rounding + "Feature Callout is positioned left of parent element with 5px overlap" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_second_screen_positioned_above_element() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentTop = document + .querySelector("#recently-closed-tabs-container") + .getBoundingClientRect().top; + let containerBottom = document + .querySelector(calloutSelector) + .getBoundingClientRect().bottom; + + Assert.greaterOrEqual( + parentTop, + containerBottom - 5 - 1, + "Feature Callout is positioned above parent element with 5px overlap" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_third_screen_position_respects_RTL_layouts() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + ], + }); + + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + const parent = document.querySelector( + "#recently-closed-tabs-container" + ); + parent.style.gridArea = "1/2"; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentRight = parent.getBoundingClientRect().right; + let containerLeft = document + .querySelector(calloutSelector) + .getBoundingClientRect().left; + + Assert.lessOrEqual( + parentRight, + containerLeft + 5 + 1, + "Feature Callout is positioned right of parent element when callout is set to 'end' in RTL layouts" + ); + } + ); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_is_repositioned_if_parent_container_is_toggled() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + const parentEl = document.querySelector("#tab-pickup-container"); + const calloutStartingTopPosition = document.querySelector( + calloutSelector + ).style.top; + + //container has been toggled/minimized + parentEl.removeAttribute("open", ""); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributes: true }, + () => + document.querySelector(calloutSelector).style.top != + calloutStartingTopPosition + ); + isnot( + document.querySelector(calloutSelector).style.top, + calloutStartingTopPosition, + "Feature Callout position is recalculated when parent element is toggled" + ); + await closeCallout(document); + } + ); + sandbox.restore(); + } +); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task(async function feature_callout_top_end_positioning() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "top-end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + ok( + container.classList.contains("arrow-top-end"), + "Feature Callout container has the expected arrow-top-end class" + ); + isfuzzy( + containerLeft - parent.clientWidth + container.offsetWidth, + parentLeft, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout's right edge is approximately aligned with parent element's right edge" + ); + + await closeCallout(document); + } + ); + sandbox.restore(); +}); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task(async function feature_callout_top_start_positioning() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "top-start"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + ok( + container.classList.contains("arrow-top-start"), + "Feature Callout container has the expected arrow-top-start class" + ); + isfuzzy( + containerLeft, + parentLeft, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout's left edge is approximately aligned with parent element's left edge" + ); + + await closeCallout(document); + } + ); + sandbox.restore(); +}); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task( + async function feature_callout_top_end_position_respects_RTL_layouts() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + ], + }); + + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "top-end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + ok( + container.classList.contains("arrow-top-start"), + "In RTL mode, the feature Callout container has the expected arrow-top-start class" + ); + is( + containerLeft, + parentLeft, + "In RTL mode, the feature Callout's left edge is aligned with parent element's left edge" + ); + + await closeCallout(document); + } + ); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + } +); + +add_task(async function feature_callout_is_larger_than_its_parent() { + let testMessage = { + message: { + id: "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS", + template: "feature_callout", + content: { + id: "FIREFOX_VIEW_FEATURE_TOUR", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FEATURE_CALLOUT_1", + parent_selector: ".brand-icon", + content: { + position: "callout", + arrow_position: "end", + title: "callout-firefox-view-tab-pickup-title", + subtitle: { + string_id: "callout-firefox-view-tab-pickup-subtitle", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + height: "128px", // .brand-icon has a height of 32px + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + }, + }; + + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, getPrefValueByScreen(1)]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector(".brand-icon"); + let container = document.querySelector(calloutSelector); + let parentHeight = parent.offsetHeight; + let containerHeight = container.offsetHeight; + + let parentPositionTop = + parent.getBoundingClientRect().top + window.scrollY; + let containerPositionTop = + container.getBoundingClientRect().top + window.scrollY; + Assert.greater( + containerHeight, + parentHeight, + "Feature Callout is height is larger than parent element when callout is configured at end of callout" + ); + Assert.less( + containerPositionTop, + parentPositionTop, + "Feature Callout is positioned higher that parent element when callout is configured at end of callout" + ); + isfuzzy( + containerHeight / 2 + containerPositionTop, + parentHeight / 2 + parentPositionTop, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout is centered equally to parent element when callout is configured at end of callout" + ); + await ASRouter.resetMessageState(); + } + ); + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js new file mode 100644 index 0000000000..1f9d00975a --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js @@ -0,0 +1,127 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const featureTourPref = "browser.firefox-view.feature-tour"; + +add_setup(async function setup() { + let originalWidth = window.outerWidth; + let originalHeight = window.outerHeight; + registerCleanupFunction(async () => { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => window.FullZoom.reset(browser) + ); + window.resizeTo(originalWidth, originalHeight); + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => window.FullZoom.setZoom(0.5, browser) + ); +}); + +add_task(async function feature_callout_is_repositioned_if_it_does_not_fit() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.max_tabs_undo", 1]], + }); + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => { + const { document } = browser.contentWindow; + + browser.contentWindow.resizeTo(1550, 1000); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1550x1000, the callout is positioned below the parent element" + ); + + let startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1600, 400); + // Wait for callout to be repositioned + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-inline-start`), + "On first screen at 1600x400, the callout is positioned to the right of the parent element" + ); + + startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1100, 600); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1100x600, the callout is positioned below the parent element" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_is_repositioned_rtl() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + ["browser.sessionstore.max_tabs_undo", 1], + ], + }); + + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => { + const { document } = browser.contentWindow; + + browser.contentWindow.resizeTo(1550, 1000); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1550x1000, the callout is positioned below the parent element" + ); + + let startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1600, 400); + // Wait for callout to be repositioned + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-inline-end`), + "On first screen at 1600x400, the callout is positioned to the right of the parent element" + ); + + startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1100, 600); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1100x600, the callout is positioned below the parent element" + ); + } + ); + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js new file mode 100644 index 0000000000..ccba3e4560 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js @@ -0,0 +1,175 @@ +"use strict"; + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +add_task( + async function test_firefox_view_tab_pick_up_not_signed_in_targeting() { + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Firefox:View Tab Pickup should be displayed." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); + +add_task( + async function test_firefox_view_tab_pick_up_sync_not_enabled_targeting() { + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", true]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", false]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.username", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Firefox:View Tab Pickup should be displayed." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); + +add_task( + async function test_firefox_view_tab_pick_up_wait_24_hours_after_spotlight() { + const TWENTY_FIVE_HOURS_IN_MS = 25 * 60 * 60 * 1000; + + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", false]], + }); + + ASRouter.setState({ + messageImpressions: { FIREFOX_VIEW_SPOTLIGHT: [Date.now()] }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + ok( + !document.querySelector(".featureCallout"), + "Tab Pickup reminder should not be displayed when the Spotlight message introducing the tour was viewed less than 24 hours ago." + ); + } + ); + + ASRouter.setState({ + messageImpressions: { + FIREFOX_VIEW_SPOTLIGHT: [Date.now() - TWENTY_FIVE_HOURS_IN_MS], + }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Tab Pickup reminder can be displayed when the Spotlight message introducing the tour was viewed over 24 hours ago." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_firefoxview.js new file mode 100644 index 0000000000..5ac9dd4c7b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function about_firefoxview_smoke_test() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // sanity check the important regions exist on this page + ok( + document.getElementById("tab-pickup-container"), + "tab-pickup-container element exists" + ); + ok( + document.getElementById("recently-closed-tabs-container"), + "recently-closed-tabs-container element exists" + ); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js new file mode 100644 index 0000000000..e4b2e866a0 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that are related to the accessibility of the Firefox View + * document. These tasks tend to be privileged content, not browser + * chrome. + */ + +add_setup(async function setup() { + // Make sure the prompt to connect FxA doesn't show + // Without resetting the view-count pref it gets surfaced after + // the third click on the fx view toolbar button. + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 0]], + }); +}); + +add_task(async function test_keyboard_focus_after_tab_pickup_opened() { + // Reset various things touched by other tests in this file so that + // we have a sufficiently clean environment. + + TabsSetupFlowManager.resetInternalState(); + + // Ensure that the tab-pickup section doesn't need to be opened. + Services.prefs.clearUserPref( + "browser.tabs.firefox-view.ui-state.tab-pickup.open" + ); + + // make sure the feature tour doesn't get in the way + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.firefox-view.feature-tour", + JSON.stringify({ + screen: `FEATURE_CALLOUT_1`, + complete: true, + }), + ], + ], + }); + + // Let's be deterministic about the basic UI state! + const sandbox = setupMocks({ + state: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + + is( + document.activeElement.localName, + "body", + "document body element is initially focused" + ); + + const tab = () => { + info("Tab keypress synthesized"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + }; + + tab(); + + let tabPickupContainer = document.querySelector( + "#tab-pickup-container summary.page-section-header" + ); + is( + document.activeElement, + tabPickupContainer, + "tab pickup container header has focus" + ); + + tab(); + + is( + document.activeElement.id, + "firefoxview-tabpickup-step-signin-primarybutton", + "tab pickup primary button has focus" + ); + }); + + // cleanup time + await tearDown(sandbox); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_keyboard_accessibility_tab_pickup() { + await withFirefoxView({}, async browser => { + const win = browser.ownerGlobal; + const { document } = browser.contentWindow; + const enter = async () => { + info("Enter"); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + }; + let details = document.getElementById("tab-pickup-container"); + let summary = details.querySelector("summary"); + ok(summary, "summary element should exist"); + ok(details.open, "Tab pickup container should be initially open on load"); + summary.focus(); + await enter(); + ok(!details.open, "Tab pickup container should be closed"); + await enter(); + ok(details.open, "Tab pickup container should be opened"); + }); + cleanup_tab_pickup(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js new file mode 100644 index 0000000000..17485e54e1 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that are related to the accessibility of the feature callout + */ + +/** + * Ensure feature tour is accessible using a screen reader and with + * keyboard navigation. + */ +add_task(async function feature_callout_is_accessible() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.feature-tour", getPrefValueByScreen(1)]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + await BrowserTestUtils.waitForCondition( + () => document.activeElement.id === calloutId, + "Feature Callout is focused on page load" + ); + ok(true, "Feature Callout was focused on page load"); + + await BrowserTestUtils.waitForCondition( + () => + document.querySelector( + `${calloutSelector}[aria-describedby="#${calloutId} .welcome-text"]` + ), + "The callout container has an aria-describedby value equal to the screen welcome text" + ); + ok(true, "The callout container has the correct aria-describedby value"); + + // Advance to second screen + clickPrimaryButton(document); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + + ok(true, "FEATURE_CALLOUT_2 was successfully displayed"); + await BrowserTestUtils.waitForCondition( + () => document.activeElement.id === calloutId, + "Feature Callout is focused after advancing screens" + ); + ok(true, "Feature Callout was successfully focused"); + } + ); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js new file mode 100644 index 0000000000..f178ad4f32 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function expectFocusAfterKey( + aKey, + aFocus, + aAncestorOk = false, + aWindow = window +) { + let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/); + let shift = Boolean(res[1]); + let key; + if (res[2]) { + key = res[2]; // Character. + } else { + key = "KEY_" + res[3]; // Tab, ArrowRight, etc. + } + let expected; + let friendlyExpected; + if (typeof aFocus == "string") { + expected = aWindow.document.getElementById(aFocus); + friendlyExpected = aFocus; + } else { + expected = aFocus; + if (aFocus == aWindow.gURLBar.inputField) { + friendlyExpected = "URL bar input"; + } else if (aFocus == aWindow.gBrowser.selectedBrowser) { + friendlyExpected = "Web document"; + } + } + info("Listening on item " + (expected.id || expected.className)); + let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk); + EventUtils.synthesizeKey(key, { shiftKey: shift }, aWindow); + let receivedEvent = await focused; + info( + "Got focus on item: " + + (receivedEvent.target.id || receivedEvent.target.className) + ); + ok(true, friendlyExpected + " focused after " + aKey + " pressed"); +} + +function forceFocus(aElem) { + aElem.setAttribute("tabindex", "-1"); + aElem.focus(); + aElem.removeAttribute("tabindex"); +} + +add_task(async function aria_attributes() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + is( + win.FirefoxViewHandler.button.getAttribute("role"), + "button", + "Firefox View button should have the 'button' ARIA role" + ); + await openFirefoxViewTab(win); + isnot( + win.FirefoxViewHandler.button.getAttribute("aria-controls"), + "", + "Firefox View button should have non-empty `aria-controls` attribute" + ); + is( + win.FirefoxViewHandler.button.getAttribute("aria-controls"), + win.FirefoxViewHandler.tab.linkedPanel, + "Firefox View button should refence the hidden tab's linked panel via `aria-controls`" + ); + is( + win.FirefoxViewHandler.button.getAttribute("aria-pressed"), + "true", + 'Firefox View button should have `aria-pressed="true"` upon selecting it' + ); + win.BrowserOpenTab(); + is( + win.FirefoxViewHandler.button.getAttribute("aria-pressed"), + "false", + 'Firefox View button should have `aria-pressed="false"` upon selecting a different tab' + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function load_opens_new_tab() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + win.gURLBar.focus(); + win.gURLBar.value = "https://example.com"; + let newTabOpened = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + info( + "Waiting for new tab to open from the address bar in the Firefox View tab" + ); + await newTabOpened; + assertFirefoxViewTab(win); + ok( + !win.FirefoxViewHandler.tab.selected, + "Firefox View tab is not selected anymore (new tab opened in the foreground)" + ); + }); +}); + +add_task(async function homepage_new_tab() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + let newTabOpened = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "TabOpen" + ); + win.BrowserHome(); + info("Waiting for BrowserHome() to open a new tab"); + await newTabOpened; + assertFirefoxViewTab(win); + ok( + !win.FirefoxViewHandler.tab.selected, + "Firefox View tab is not selected anymore (home page opened in the foreground)" + ); + }); +}); + +add_task(async function number_tab_select_shortcut() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + EventUtils.synthesizeKey( + "1", + AppConstants.MOZ_WIDGET_GTK ? { altKey: true } : { accelKey: true }, + win + ); + ok( + !win.FirefoxViewHandler.tab.selected, + "Number shortcut to select the first tab skipped the Firefox View tab" + ); + }); +}); + +add_task(async function accel_w_behavior() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await openFirefoxViewTab(win); + EventUtils.synthesizeKey("w", { accelKey: true }, win); + ok(!win.FirefoxViewHandler.tab, "Accel+w closed the Firefox View tab"); + await openFirefoxViewTab(win); + win.gBrowser.selectedTab = win.gBrowser.visibleTabs[0]; + info( + "Waiting for Accel+W in the only visible tab to close the window, ignoring the presence of the hidden Firefox View tab" + ); + let windowClosed = BrowserTestUtils.windowClosed(win); + EventUtils.synthesizeKey("w", { accelKey: true }, win); + await windowClosed; +}); + +add_task(async function undo_close_tab() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(win), + 0, + "Closed tab count after purging session history" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:about" + ); + await TestUtils.waitForTick(); + + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + win.gBrowser.removeTab(tab); + await sessionUpdatePromise; + is( + SessionStore.getClosedTabCount(win), + 1, + "Closing about:about added to the closed tab count" + ); + + let viewTab = await openFirefoxViewTab(win); + await TestUtils.waitForTick(); + sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(viewTab); + closeFirefoxViewTab(win); + await sessionUpdatePromise; + is( + SessionStore.getClosedTabCount(win), + 1, + "Closing the Firefox View tab did not add to the closed tab count" + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_firefoxview_view_count() { + const startViews = 2; + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", startViews]], + }); + + let tab = await openFirefoxViewTab(window); + + ok( + SpecialPowers.getIntPref("browser.firefox-view.view-count") === + startViews + 1, + "View count pref value is incremented when tab is selected" + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_add_ons_cant_unhide_fx_view() { + // Test that add-ons can't unhide the Firefox View tab by calling + // browser.tabs.show(). See bug 1791770 for details. + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:about" + ); + let viewTab = await openFirefoxViewTab(win); + win.gBrowser.hideTab(tab); + + ok(tab.hidden, "Regular tab is hidden"); + ok(viewTab.hidden, "Firefox View tab is hidden"); + + win.gBrowser.showTab(tab); + win.gBrowser.showTab(viewTab); + + ok(!tab.hidden, "Add-on showed regular hidden tab"); + ok(viewTab.hidden, "Add-on did not show Firefox View tab"); + + await BrowserTestUtils.closeWindow(win); +}); + +// Test navigation to first visible tab when the +// Firefox View button is present and active. +add_task(async function testFirstTabFocusableWhenFxViewOpen() { + await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }); + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + let fxViewBtn = win.document.getElementById("firefox-view-button"); + forceFocus(fxViewBtn); + is( + win.document.activeElement, + fxViewBtn, + "Firefox View button focused for start of test" + ); + let firstVisibleTab = win.gBrowser.visibleTabs[0]; + await expectFocusAfterKey("Tab", firstVisibleTab, false, win); + let activeElement = win.document.activeElement; + let expectedElement = firstVisibleTab; + is(activeElement, expectedElement, "First visible tab should be focused"); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js b/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js new file mode 100644 index 0000000000..aaeb2b7792 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +const SYNCED_URI = syncedTabsData1[0].tabs[1].url; + +add_task(async function test_keyboard_focus() { + await SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + syncedTabsMock.returns(mockTabs1); + + await setupListState(browser); + + testVisibility(browser, { + expectedVisible: { + "ol.synced-tabs-list": true, + }, + }); + + let tabPickupEle = document.querySelector(".synced-tab-a"); + document.querySelector(".page-section-header").focus(); + + EventUtils.synthesizeKey("KEY_Tab"); + + is( + tabPickupEle, + document.activeElement, + "The first tab pickup link is focused" + ); + + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, SYNCED_URI); + EventUtils.synthesizeKey("KEY_Enter"); + await newTabPromise; + + is( + SYNCED_URI, + gBrowser.selectedBrowser.currentURI.displaySpec, + "We opened the tab via keyboard" + ); + + let sessionStorePromise = BrowserTestUtils.waitForSessionStoreUpdate( + gBrowser.selectedTab + ); + gBrowser.removeTab(gBrowser.selectedTab); + await sessionStorePromise; + + window.FirefoxViewHandler.openTab(); + + let recentlyClosedEle = document.querySelector(".closed-tab-li-main"); + document.querySelectorAll(".page-section-header")[1].focus(); + + EventUtils.synthesizeKey("KEY_Tab"); + + is( + recentlyClosedEle, + document.activeElement, + "The recently closed tab is focused" + ); + + newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, SYNCED_URI); + EventUtils.synthesizeKey("KEY_Enter"); + await newTabPromise; + is( + SYNCED_URI, + gBrowser.selectedBrowser.currentURI.displaySpec, + "We opened the tab via keyboard" + ); + gBrowser.removeTab(gBrowser.selectedTab); + + sessionStorePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + SessionStore.forgetClosedTab(window, 0); + await sessionStorePromise; + + sandbox.restore(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_media_query_dom_sorting.js b/browser/components/firefoxview/tests/browser/browser_media_query_dom_sorting.js new file mode 100644 index 0000000000..e10158504b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_media_query_dom_sorting.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const WIDE_WINDOW_WIDTH = 1100; +const NARROW_WINDOW_WIDTH = 900; + +function getTestElements(doc) { + return { + recentlyClosedTabs: doc.getElementById("recently-closed-tabs-container"), + colorways: doc.getElementById("colorways"), + }; +} + +function iscolorwaysBeforeRecentlyClosedTabs(document) { + const recentlyClosedTabs = document.getElementById( + "recently-closed-tabs-container" + ); + const colorways = document.getElementById("colorways"); + return recentlyClosedTabs.previousElementSibling === colorways; +} + +async function resizeWindow(win, width) { + const resizePromise = BrowserTestUtils.waitForEvent(win, "resize"); + win.windowUtils.ensureDirtyRootFrame(); + info("Resizing window..."); + win.resizeTo(width, win.outerHeight); + await resizePromise; +} + +add_task(async function media_query_less_than_65em() { + await withFirefoxView({}, async browser => { + let win = browser.contentWindow; + const { recentlyClosedTabs, colorways } = getTestElements(win.document); + await resizeWindow(win, NARROW_WINDOW_WIDTH); + is( + recentlyClosedTabs.previousSibling, + colorways, + "colorway card has been positioned before recently closed tabs" + ); + }); +}); + +add_task(async function media_query_more_than_65em() { + await withFirefoxView({}, async browser => { + let win = browser.contentWindow; + const { recentlyClosedTabs, colorways } = getTestElements(win.document); + await resizeWindow(win, WIDE_WINDOW_WIDTH); + is( + recentlyClosedTabs.nextSibling, + colorways, + "colorway card has been positioned after recently closed tabs" + ); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_notification_dot.js b/browser/components/firefoxview/tests/browser/browser_notification_dot.js new file mode 100644 index 0000000000..729377bf8d --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js @@ -0,0 +1,335 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const tabsList1 = syncedTabsData1[0].tabs; +const tabsList2 = syncedTabsData1[1].tabs; +const BADGE_TOP_RIGHT = "75% 25%"; + +const { SyncedTabs } = ChromeUtils.import( + "resource://services-sync/SyncedTabs.jsm" +); + +const { FirefoxViewNotificationManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-notification-manager.sys.mjs" +); + +function setupRecentDeviceListMocks() { + const sandbox = sinon.createSandbox(); + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [ + { + id: 1, + name: "My desktop", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "My iphone", + type: "mobile", + }, + ]); + + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + }); + + return sandbox; +} + +function waitForWindowActive(win, active) { + info("Waiting for window activation"); + return Promise.all([ + BrowserTestUtils.waitForEvent(win, active ? "focus" : "blur"), + BrowserTestUtils.waitForEvent(win, active ? "activate" : "deactivate"), + ]); +} + +async function waitForNotificationBadgeToBeShowing(fxViewButton) { + info("Waiting for attention attribute to be set"); + await BrowserTestUtils.waitForMutationCondition( + fxViewButton, + { attributes: true }, + () => fxViewButton.hasAttribute("attention") + ); + return fxViewButton.hasAttribute("attention"); +} + +async function waitForNotificationBadgeToBeHidden(fxViewButton) { + info("Waiting for attention attribute to be removed"); + await BrowserTestUtils.waitForMutationCondition( + fxViewButton, + { attributes: true }, + () => !fxViewButton.hasAttribute("attention") + ); + return !fxViewButton.hasAttribute("attention"); +} + +function getBackgroundPositionForElement(ele) { + let style = ele.ownerGlobal.getComputedStyle(ele); + return style.getPropertyValue("background-position"); +} + +let recentFetchTime = Math.floor(Date.now() / 1000); +async function initTabSync() { + recentFetchTime += 1; + info("updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + await TestUtils.waitForTick(); +} + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.notify-for-tabs", true]], + }); + + // Clear any synced tabs from previous tests + FirefoxViewNotificationManager.syncedTabs = null; + Services.obs.notifyObservers( + null, + "firefoxview-notification-dot-update", + "false" + ); +}); + +/** + * Test that the notification badge will show and hide in the correct cases + */ +add_task(async function testNotificationDot() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + sandbox.spy(SyncedTabs, "syncTabs"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let fxViewBtn = win.document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + + // Initiate a synced tabs update with new tabs + syncedTabsMock.returns(tabsList1); + await initTabSync(); + + ok( + BrowserTestUtils.is_visible(fxViewBtn), + "The Firefox View button is showing" + ); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing initially" + ); + + // Initiate a synced tabs update with new tabs + syncedTabsMock.returns(tabsList2); + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing after first tab sync" + ); + + // check that switching to the firefoxviewtab removes the badge + fxViewBtn.click(); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after going to Firefox View" + ); + + await BrowserTestUtils.waitForCondition(() => { + return SyncedTabs.syncTabs.calledOnce; + }); + + ok(SyncedTabs.syncTabs.calledOnce, "SyncedTabs.syncTabs() was called once"); + + syncedTabsMock.returns(tabsList1); + // Initiate a synced tabs update with new tabs + await initTabSync(); + + // The noti badge would show but we are on a Firefox View page so no need to show the noti badge + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after tab sync while Firefox View is focused" + ); + + let newTab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + syncedTabsMock.returns(tabsList2); + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing after navigation to a new tab" + ); + + // check that switching back to the Firefox View tab removes the badge + fxViewBtn.click(); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after focusing the Firefox View tab" + ); + + await BrowserTestUtils.switchTab(win.gBrowser, newTab); + + // Initiate a synced tabs update with no new tabs + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after a tab sync with the same tabs" + ); + + await BrowserTestUtils.closeWindow(win); + + sandbox.restore(); +}); + +/** + * Tests the notification badge with multiple windows + */ +add_task(async function testNotificationDotOnMultipleWindows() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + + // Create a new window + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + await win1.delayedStartupPromise; + let fxViewBtn = win1.document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + + syncedTabsMock.returns(tabsList1); + // Initiate a synced tabs update + await initTabSync(); + + // Create another window + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + await win2.delayedStartupPromise; + let fxViewBtn2 = win2.document.getElementById("firefox-view-button"); + + fxViewBtn2.click(); + + // Make sure the badge doesn't show on any window + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing in the inital window" + ); + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn2), + "The notification badge is not showing in the second window" + ); + + // Minimize the window. + win2.minimize(); + + await TestUtils.waitForCondition( + () => !win2.gBrowser.selectedBrowser.docShellIsActive, + "Waiting for docshell to be marked as inactive after minimizing the window" + ); + + syncedTabsMock.returns(tabsList2); + info("Initiate a synced tabs update with new tabs"); + await initTabSync(); + + // The badge will show because the View tab is minimized + // Make sure the badge shows on all windows + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing in the initial window" + ); + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn2), + "The notification badge is showing in the second window" + ); + + win2.restore(); + await TestUtils.waitForCondition( + () => win2.gBrowser.selectedBrowser.docShellIsActive, + "Waiting for docshell to be marked as active after restoring the window" + ); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + + sandbox.restore(); +}); + +/** + * Tests the notification badge is in the correct spot and that the badge shows when opening a new window + * if another window is showing the badge + */ +add_task(async function testNotificationDotLocation() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + + syncedTabsMock.returns(tabsList1); + + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let fxViewBtn = win1.document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + + // Initiate a synced tabs update + await initTabSync(); + syncedTabsMock.returns(tabsList2); + // Initiate another synced tabs update + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing initially" + ); + + // Create a new window + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + await win2.delayedStartupPromise; + + // Make sure the badge doesn't showing on the new window + let fxViewBtn2 = win2.document.getElementById("firefox-view-button"); + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn2), + "The notification badge is showing in the second window after opening" + ); + + // Make sure the badge is below and center now + isnot( + getBackgroundPositionForElement(fxViewBtn), + BADGE_TOP_RIGHT, + "The notification badge is not showing in the top right in the initial window" + ); + isnot( + getBackgroundPositionForElement(fxViewBtn2), + BADGE_TOP_RIGHT, + "The notification badge is not showing in the top right in the second window" + ); + + CustomizableUI.addWidgetToArea( + "firefox-view-button", + CustomizableUI.AREA_NAVBAR + ); + + // Make sure both windows still have the notification badge + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing in the initial window" + ); + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn2), + "The notification badge is showing in the second window" + ); + + // Make sure the badge is in the top right now + is( + getBackgroundPositionForElement(fxViewBtn), + BADGE_TOP_RIGHT, + "The notification badge is showing in the top right in the initial window" + ); + is( + getBackgroundPositionForElement(fxViewBtn2), + BADGE_TOP_RIGHT, + "The notification badge is showing in the top right in the second window" + ); + + CustomizableUI.reset(); + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js new file mode 100644 index 0000000000..988a576327 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js @@ -0,0 +1,798 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * The recently closed tab list is populated on a per-window basis. + * + * By default, the withFirefoxView helper opens a new window. + * When using this helper for the tests in this file, we pass a + * { win: window } option to skip that step and open fx view in + * the current window. This ensures that the add_new_tab, close_tab, + * and open_then_close functions are creating sessionstore entries + * associated with the correct window where the tests are run. + */ + +ChromeUtils.defineESModuleGetters(globalThis, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const RECENTLY_CLOSED_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "recently_closed", "tabs", undefined], +]; + +const CLOSED_TABS_OPEN_EVENT = [ + ["firefoxview", "closed_tabs_open", "tabs", "false"], +]; + +const RECENTLY_CLOSED_DISMISS_EVENT = [ + ["firefoxview", "dismiss_closed_tab", "tabs", undefined], +]; + +async function add_new_tab(URL) { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +async function close_tab(tab) { + const sessionStorePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionStorePromise; +} + +async function dismiss_tab(tab, content) { + info(`Dismissing tab ${tab.dataset.targetURI}`); + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + let dismissButton = tab.querySelector(".closed-tab-li-dismiss"); + EventUtils.synthesizeMouseAtCenter(dismissButton, {}, content); + await closedObjectsChanged(); +} + +add_task(async function test_empty_list() { + clearHistory(); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + let container = document.querySelector("#collapsible-tabs-container"); + ok( + container.classList.contains("empty-container"), + "collapsible container should have correct styling when the list is empty" + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": true, + "ol.closed-tabs-list": false, + }, + }); + + const tab1 = await add_new_tab(URLs[0]); + + await close_tab(tab1); + + // The UI update happens asynchronously as we learn of the new closed tab. + await BrowserTestUtils.waitForMutationCondition( + container, + { attributeFilter: ["class"] }, + () => !container.classList.contains("empty-container") + ); + ok( + !container.classList.contains("empty-container"), + "collapsible container should have correct styling when the list is not empty" + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": false, + "ol.closed-tabs-list": true, + }, + }); + + is( + document.querySelector("ol.closed-tabs-list").children.length, + 1, + "recently-closed-tabs-list should have one list item" + ); + }); +}); + +add_task(async function test_list_ordering() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + await clearAllParentTelemetryEvents(); + + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + + const tab1 = await add_new_tab(URLs[0]); + const tab2 = await add_new_tab(URLs[1]); + const tab3 = await add_new_tab(URLs[2]); + + gBrowser.selectedTab = tab3; + + await close_tab(tab3); + await closedObjectsChanged(); + + await close_tab(tab2); + await closedObjectsChanged(); + + await close_tab(tab1); + await closedObjectsChanged(); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const tabsList = document.querySelector("ol.closed-tabs-list"); + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length > 1 + ); + + is( + document.querySelector("ol.closed-tabs-list").children.length, + 3, + "recently-closed-tabs-list should have three list items" + ); + + // check that the ordering is correct when user navigates to another tab, and then closes multiple tabs. + ok( + document + .querySelector("ol.closed-tabs-list") + .firstChild.textContent.includes("mochi.test"), + "first list item in recently-closed-tabs-list is in the correct order" + ); + + ok( + document + .querySelector("ol.closed-tabs-list") + .children[2].textContent.includes("example.net"), + "last list item in recently-closed-tabs-list is in the correct order" + ); + + let ele = document.querySelector("ol.closed-tabs-list").firstElementChild; + let uri = ele.getAttribute("data-target-u-r-i"); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, uri); + ele.click(); + await newTabPromise; + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and recently_closed firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + gBrowser.removeTab(gBrowser.selectedTab); + + await clearAllParentTelemetryEvents(); + + await waitForElementVisible( + browser, + "#recently-closed-tabs-container > summary" + ); + document.querySelector("#recently-closed-tabs-container > summary").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for closed_tabs_open firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + CLOSED_TABS_OPEN_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + }); +}); + +add_task(async function test_max_list_items() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + // Seed the closed tabs count. We've assured that we've opened and + // closed at least three tabs because of the calls to open_then_close + // above. + let mockMaxTabsLength = 3; + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + // override this value for testing purposes + document.querySelector( + "recently-closed-tabs-list" + ).maxTabsLength = mockMaxTabsLength; + + ok( + !document + .querySelector("#collapsible-tabs-container") + .classList.contains("empty-container"), + "collapsible container should have correct styling when the list is not empty" + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": false, + "ol.closed-tabs-list": true, + }, + }); + + is( + document.querySelector("ol.closed-tabs-list").childNodes.length, + mockMaxTabsLength, + `recently-closed-tabs-list should have ${mockMaxTabsLength} list items` + ); + + const closedObjectsChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + // add another tab + const tab = await add_new_tab(URLs[3]); + await close_tab(tab); + await closedObjectsChanged; + + let firstListItem = document.querySelector("ol.closed-tabs-list") + .firstChild; + await BrowserTestUtils.waitForMutationCondition( + firstListItem, + { characterData: true, childList: true, subtree: true }, + () => firstListItem.textContent.includes(".org") + ); + ok( + firstListItem.textContent.includes("example.org"), + "first list item in recently-closed-tabs-list should have been updated" + ); + + is( + document.querySelector("ol.closed-tabs-list").childNodes.length, + mockMaxTabsLength, + `recently-closed-tabs-list should still have ${mockMaxTabsLength} list items` + ); + }); +}); + +add_task(async function test_time_updates_correctly() { + clearHistory(); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + // Set the closed tabs state to include one tab that was closed 2 seconds ago. + // This is well below the initial threshold for displaying the 'Just now' timestamp. + // It is also much greater than the 5ms threshold we use for the updated pref value, + // which results in the timestamp text changing after the pref value is changed. + const TAB_CLOSED_AGO_MS = 2000; + const TAB_UPDATE_TIME_MS = 5; + const TAB_CLOSED_STATE = { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [ + { + state: { entries: [{ url: "https://www.example.com/" }] }, + closedId: 0, + closedAt: Date.now() - TAB_CLOSED_AGO_MS, + image: null, + }, + ], + }, + ], + }; + await SessionStore.setBrowserState(JSON.stringify(TAB_CLOSED_STATE)); + + is( + SessionStore.getClosedTabCount(window), + 1, + "Closed tab count after setting browser state" + ); + + await withFirefoxView( + { + win: window, + }, + async browser => { + const { document } = browser.contentWindow; + + const lastListItem = document.querySelector("ol.closed-tabs-list") + .lastChild; + const timeLabel = lastListItem.querySelector("span.closed-tab-li-time"); + let initialTimeText = timeLabel.textContent; + Assert.stringContains( + initialTimeText, + "Just now", + "recently-closed-tabs list item time is 'Just now'" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", TAB_UPDATE_TIME_MS]], + }); + + await BrowserTestUtils.waitForMutationCondition( + timeLabel, + { childList: true }, + () => !timeLabel.textContent.includes("now") + ); + + isnot( + timeLabel.textContent, + initialTimeText, + "recently-closed-tabs list item time has updated" + ); + + await SpecialPowers.popPrefEnv(); + } + ); + // Cleanup recently closed tab data. + clearHistory(); +}); + +add_task(async function test_list_maintains_focus_when_restoring_tab() { + await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await withFirefoxView({ win: window }, async browser => { + let gBrowser = browser.getTabBrowser(); + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let expectedFocusedElement = list[1].querySelector(".closed-tab-li-main"); + list[0].querySelector(".closed-tab-li-main").focus(); + EventUtils.synthesizeKey("KEY_Enter"); + let firefoxViewTab = gBrowser.tabs.find(tab => tab.label == "Firefox View"); + await BrowserTestUtils.switchTab(gBrowser, firefoxViewTab); + is( + document.activeElement, + expectedFocusedElement, + "Focus should be on the first item in the recently closed list" + ); + }); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + + clearHistory(); + await open_then_close(URLs[2]); + await withFirefoxView({ win: window }, async browser => { + let gBrowser = browser.getTabBrowser(); + const { document } = browser.contentWindow; + let expectedFocusedElement = document.getElementById( + "recently-closed-tabs-header-section" + ); + const list = document.querySelectorAll(".closed-tab-li"); + list[0].querySelector(".closed-tab-li-main").focus(); + + EventUtils.synthesizeKey("KEY_Enter"); + let firefoxViewTab = gBrowser.tabs.find(tab => tab.label == "Firefox View"); + await BrowserTestUtils.switchTab(gBrowser, firefoxViewTab); + is( + document.activeElement, + expectedFocusedElement, + "Focus should be on the section header" + ); + }); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +add_task(async function test_switch_before_closing() { + clearHistory(); + + const INITIAL_URL = "https://example.org/iwilldisappear"; + const FINAL_URL = "https://example.com/ishouldappear"; + await withFirefoxView({ win: window }, async function(browser) { + let gBrowser = browser.getTabBrowser(); + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + // Switch back to FxView: + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.getTabForBrowser(browser) + ); + // Update the tab we opened to a different site: + let loadPromise = BrowserTestUtils.browserLoaded( + newTab.linkedBrowser, + null, + FINAL_URL + ); + BrowserTestUtils.loadURI(newTab.linkedBrowser, FINAL_URL); + await loadPromise; + + // Close the added tab + BrowserTestUtils.removeTab(newTab); + + const { document } = browser.contentWindow; + const tabsList = document.querySelector("ol.closed-tabs-list"); + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => !!tabsList.children.length + ); + info("A tab appeared in the list, ensure it has the right URL."); + let urlBit = tabsList.firstElementChild.querySelector(".closed-tab-li-url"); + await BrowserTestUtils.waitForMutationCondition( + urlBit, + { characterData: true, attributeFilter: ["title"] }, + () => urlBit.textContent.includes(".com") + ); + is( + urlBit.textContent, + "example.com", + "Item should end up with the correct URL." + ); + }); +}); + +add_task(async function test_alt_click_no_launch() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + await open_then_close(URLs[0]); + + await withFirefoxView({ win: window }, async browser => { + let gBrowser = browser.getTabBrowser(); + let originalTabsLength = gBrowser.tabs.length; + await BrowserTestUtils.synthesizeMouseAtCenter( + ".closed-tab-li", + { altKey: true }, + browser + ); + + is( + gBrowser.tabs.length, + originalTabsLength, + `Opened tabs length should still be ${originalTabsLength}` + ); + }); +}); + +/** + * Asserts that tabs that have been recently closed can be + * restored by clicking on them, using the Enter key, + * and using the Space bar. + */ +add_task(async function test_restore_recently_closed_tabs() { + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + // Wait for Firefox View to be loaded before interacting + // with the page. + await BrowserTestUtils.browserLoaded( + window.FirefoxViewHandler.tab.linkedBrowser + ); + let { document } = gBrowser.contentWindow; + let tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[2]); + EventUtils.synthesizeMouseAtCenter( + document.querySelector(".closed-tab-li"), + {}, + gBrowser.contentWindow + ); + + await tabRestored; + ok(true, "Tab was restored by mouse click"); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[1]); + document.querySelector(".closed-tab-li .closed-tab-li-main").focus(); + EventUtils.synthesizeKey("KEY_Enter", {}, gBrowser.contentWindow); + + await tabRestored; + ok(true, "Tab was restored by using the Enter key"); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[0]); + document.querySelector(".closed-tab-li .closed-tab-li-main").focus(); + EventUtils.synthesizeKey(" ", {}, gBrowser.contentWindow); + + await tabRestored; + ok(true, "Tab was restored by using the Space bar"); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +/** + * Asserts that tabs are removed from Recently Closed tabs in + * Fx View when tabs are removed from latest closed tab data. + * Ex: Selecting "Reopen Closed Tab" from the tabs toolbar + * context menu + */ +add_task(async function test_reopen_recently_closed_tabs() { + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + // Wait for Firefox View to be loaded before interacting + // with the page. + await BrowserTestUtils.browserLoaded( + window.FirefoxViewHandler.tab.linkedBrowser + ); + + let { document } = gBrowser.contentWindow; + + let tabReopened = BrowserTestUtils.waitForNewTab(gBrowser, URLs[2]); + SessionStore.undoCloseTab(window); + await tabReopened; + + const tabsList = document.querySelector("ol.closed-tabs-list"); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length === 2 + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + await close_tab(gBrowser.visibleTabs[gBrowser.visibleTabs.length - 1]); + + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length === 3 + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[2], + `First recently closed item should be ${URLs[2]}` + ); + + await dismiss_tab(tabsList.children[0], content); + + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length === 2 + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +/** + * Asserts that tabs that have been recently closed can be + * dismissed by clicking on their respective dismiss buttons. + */ +add_task(async function test_dismiss_tab() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + Assert.equal( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + await clearAllParentTelemetryEvents(); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + + const tab1 = await add_new_tab(URLs[0]); + const tab2 = await add_new_tab(URLs[1]); + const tab3 = await add_new_tab(URLs[2]); + + await close_tab(tab3); + await closedObjectsChanged(); + + await close_tab(tab2); + await closedObjectsChanged(); + + await close_tab(tab1); + await closedObjectsChanged(); + + const tabsList = document.querySelector("ol.closed-tabs-list"); + + await clearAllParentTelemetryEvents(); + + await dismiss_tab(tabsList.children[0], content); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for dismiss_closed_tab firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_DISMISS_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + Assert.equal( + tabsList.children.length, + 2, + "recently-closed-tabs-list should have two list items" + ); + + await clearAllParentTelemetryEvents(); + + await dismiss_tab(tabsList.children[0], content); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for dismiss_closed_tab firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_DISMISS_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[2], + `First recently closed item should be ${URLs[2]}` + ); + + Assert.equal( + tabsList.children.length, + 1, + "recently-closed-tabs-list should have one list item" + ); + + await clearAllParentTelemetryEvents(); + + await dismiss_tab(tabsList.children[0], content); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for dismiss_closed_tab firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_DISMISS_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": true, + "ol.closed-tabs-list": false, + }, + }); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js new file mode 100644 index 0000000000..2d495934df --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js @@ -0,0 +1,255 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function dismiss_tab_keyboard(closedTab, document) { + const enter = () => { + info("Enter"); + EventUtils.synthesizeKey("KEY_Enter"); + }; + const tab = (shiftKey = false) => { + info(`${shiftKey ? "Shift + Tab" : "Tab"}`); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey }); + }; + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + let firstTabMainContent = closedTab.querySelector(".closed-tab-li-main"); + let dismissButton = closedTab.querySelector(".closed-tab-li-dismiss"); + firstTabMainContent.focus(); + tab(); + Assert.equal( + document.activeElement, + dismissButton, + "Focus should be on the dismiss button for the first item in the recently closed list" + ); + enter(); + await closedObjectsChanged(); +} + +/** + * Tests keyboard navigation of the recently closed tabs component + */ +add_task(async function test_keyboard_navigation() { + const enter = () => { + info("Enter"); + EventUtils.synthesizeKey("KEY_Enter"); + }; + const tab = (shiftKey = false) => { + info(`${shiftKey ? "Shift + Tab" : "Tab"}`); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey }); + }; + /** + * Focus the summary element and asserts that: + * - The recently closed details should be initially opened + * - The recently closed details can be opened and closed via the Enter key + * + * @param {Document} document The currently used browser's content window document + * @param {HTMLElement} summary The header section element for recently closed tabs + */ + const assertPreconditions = (document, summary) => { + let details = document.getElementById("recently-closed-tabs-container"); + ok( + details.open, + "Recently closed details should be initially open on load" + ); + summary.focus(); + enter(); + ok(!details.open, "Recently closed details should be closed"); + enter(); + ok(details.open, "Recently closed details should be opened"); + }; + await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await open_then_close(URLs[0]); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let summary = document.getElementById( + "recently-closed-tabs-header-section" + ); + + assertPreconditions(document, summary); + tab(); + + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + + tab(true); + ok( + summary.matches(":focus"), + "The container is focused when using shift+tab in the list" + ); + }); + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let summary = document.getElementById( + "recently-closed-tabs-header-section" + ); + assertPreconditions(document, summary); + + tab(); + + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + tab(); + tab(); + ok( + list[1].querySelector(".closed-tab-li-main").matches(":focus"), + "The second link is focused" + ); + tab(true); + tab(true); + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused again" + ); + + tab(true); + ok( + summary.matches(":focus"), + "The container is focused when using shift+tab in the list" + ); + }); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let summary = document.getElementById( + "recently-closed-tabs-header-section" + ); + assertPreconditions(document, summary); + + tab(); + + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + tab(); + tab(); + ok( + list[1].querySelector(".closed-tab-li-main").matches(":focus"), + "The second link is focused" + ); + tab(); + tab(); + ok( + list[2].querySelector(".closed-tab-li-main").matches(":focus"), + "The third link is focused" + ); + tab(true); + tab(true); + ok( + list[1].querySelector(".closed-tab-li-main").matches(":focus"), + "The second link is focused" + ); + tab(true); + tab(true); + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + }); +}); + +add_task(async function test_dismiss_tab_keyboard() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + Assert.equal( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + const tabsList = document.querySelector("ol.closed-tabs-list"); + + await dismiss_tab_keyboard(tabsList.children[0], document); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + Assert.equal( + tabsList.children.length, + 2, + "recently-closed-tabs-list should have two list items" + ); + + await dismiss_tab_keyboard(tabsList.children[0], document); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[0], + `First recently closed item should be ${URLs[0]}` + ); + + Assert.equal( + tabsList.children.length, + 1, + "recently-closed-tabs-list should have one list item" + ); + + await dismiss_tab_keyboard(tabsList.children[0], document); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": true, + "ol.closed-tabs-list": false, + }, + }); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js new file mode 100644 index 0000000000..f9a226bbf2 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + Ensures that the Firefox View tab can be reloaded via: + - Clicking the Refresh button in the toolbar + - Using the various keyboard shortcuts +*/ +add_task(async function test_reload_firefoxview() { + await withFirefoxView({}, async browser => { + let reloadButton = document.getElementById("reload-button"); + let tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}, browser.ownerGlobal); + await tabLoaded; + ok(true, "Firefox View loaded after clicking the Reload button"); + + let keys = [ + ["R", { accelKey: true }], + ["R", { accelKey: true, shift: true }], + ["VK_F5", {}], + ]; + + if (AppConstants.platform != "macosx") { + keys.push(["VK_F5", { accelKey: true }]); + } + + for (let key of keys) { + tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeKey(key[0], key[1], browser.ownerGlobal); + await tabLoaded; + ok(true, `Firefox view loaded after using ${key}`); + } + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_errors.js b/browser/components/firefoxview/tests/browser/browser_setup_errors.js new file mode 100644 index 0000000000..4929e93600 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_errors.js @@ -0,0 +1,374 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const { LoginTestUtils } = ChromeUtils.import( + "resource://testing-common/LoginTestUtils.jsm" +); + +async function setupWithDesktopDevices(state = UIState.STATUS_SIGNED_IN) { + const sandbox = setupSyncFxAMocks({ + state, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "desktop", + }, + ], + }); + return sandbox; +} + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); +} + +add_setup(async function() { + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["services.sync.engine.tabs", true], + ["identity.fxaccounts.enabled", true], + ], + }); + + registerCleanupFunction(async function() { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + await tearDown(gSandbox); + }); +}); + +add_task(async function test_network_offline() { + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "offline" + ); + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("connection") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("network-offline"), + "Correct message should show when network connection is lost" + ); + + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "online" + ); + + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + }); + await tearDown(sandbox); +}); + +add_task(async function test_sync_error() { + const sandbox = await setupWithDesktopDevices(); + sandbox.spy(TabsSetupFlowManager, "tryToClearError"); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("trouble syncing") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"), + "Correct message should show when there's a sync service error" + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#error-state-button", + {}, + browser + ); + + await BrowserTestUtils.waitForCondition(() => { + return TabsSetupFlowManager.tryToClearError.calledOnce; + }); + + ok( + TabsSetupFlowManager.tryToClearError.calledOnce, + "TabsSetupFlowManager.tryToClearError() was called once" + ); + + // Clear the error. + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + + // Now reopen the tab and check that sending an error state does not + // start showing the error: + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + const recentFetchTime = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + await withFirefoxView({ resetFlowManager: false }, async browser => { + const { document } = browser.contentWindow; + + await waitForElementVisible(browser, "#synced-tabs-placeholder", true); + + Services.obs.notifyObservers(null, "weave:service:sync:error"); + await TestUtils.waitForTick(); + ok( + BrowserTestUtils.is_visible( + document.getElementById("synced-tabs-placeholder") + ), + "Should still be showing the placeholder content." + ); + let stepHeader = document.getElementById("tabpickup-steps-view0-header"); + ok( + !stepHeader || BrowserTestUtils.is_hidden(stepHeader), + "Should not be showing an error state if we had previously synced successfully." + ); + + // Now drop a device: + let someDevice = gMockFxaDevices.pop(); + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + // This will trip a UI update where we decide we can't rely on + // previously synced tabs anymore (they may be from the device + // that was removed!), so we still show an error: + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("trouble syncing") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"), + "Correct message should show when there's an error and tab information is outdated." + ); + + // Sneak device back in so as not to break other tests: + gMockFxaDevices.push(someDevice); + // Clear the error. + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + + await tearDown(sandbox); +}); + +add_task(async function test_sync_error_signed_out() { + // sync error should not show if user is not signed in + let sandbox = await setupWithDesktopDevices(UIState.STATUS_NOT_CONFIGURED); + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view1", + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_sync_disconnected_error() { + // it's possible for fxa to be enabled but sync not enabled. + const sandbox = setupSyncFxAMocks({ + state: UIState.STATUS_SIGNED_IN, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // triggered when user disconnects sync in about:preferences + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + info("Waiting for the tabpickup error step to be visible"); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + info( + "Waiting for a mutation condition to ensure the right syncing error message" + ); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Turn on syncing to continue") + ); + + ok( + errorStateHeader + .getAttribute("data-l10n-id") + .includes("sync-disconnected"), + "Correct message should show when sync's been disconnected error" + ); + + let preferencesTabPromise = BrowserTestUtils.waitForNewTab( + browser.getTabBrowser(), + "about:preferences?action=choose-what-to-sync#sync", + true + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#error-state-button", + {}, + browser + ); + let preferencesTab = await preferencesTabPromise; + await BrowserTestUtils.removeTab(preferencesTab); + }); + await tearDown(sandbox); +}); + +add_task(async function test_password_change_disconnect_error() { + // When the user changes their password on another device, we get into a state + // where the user is signed out but sync is still enabled. + const sandbox = setupSyncFxAMocks({ + state: UIState.STATUS_LOGIN_FAILED, + syncEnabled: true, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // triggered by the user changing fxa password on another device + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Sign in to reconnect") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("signed-out"), + "Correct message should show when user has been logged out due to external password change." + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_multiple_errors() { + let sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + // Simulate conditions in which both the locked password and sync error + // messages could be shown + LoginTestUtils.primaryPassword.enable(); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + info("Waiting for the primary password error message to be shown"); + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Enter your Primary Password") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("password-locked"), + "Password locked error message is shown" + ); + + const errorLink = document.querySelector("#error-state-link"); + ok( + errorLink && BrowserTestUtils.is_visible(errorLink), + "Error link is visible" + ); + + // Clear the primary password error message + LoginTestUtils.primaryPassword.disable(); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + info("Waiting for the sync error message to be shown"); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("trouble syncing") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"), + "Sync error message is now shown" + ); + + ok( + errorLink && BrowserTestUtils.is_hidden(errorLink), + "Error link is now hidden" + ); + + // Clear the sync error + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js b/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js new file mode 100644 index 0000000000..655ecf1e6f --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const { LoginTestUtils } = ChromeUtils.import( + "resource://testing-common/LoginTestUtils.jsm" +); + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); +} + +function setupMocks() { + const sandbox = (gSandbox = setupRecentDeviceListMocks()); + return sandbox; +} + +add_setup(async function() { + registerCleanupFunction(async () => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + LoginTestUtils.primaryPassword.disable(); + await tearDown(gSandbox); + }); + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.username", "username@example.com"]], + }); +}); + +add_task(async function test_primary_password_locked() { + LoginTestUtils.primaryPassword.enable(); + const sandbox = setupMocks(); + + await withFirefoxView({}, async browser => { + sandbox + .stub(TabsSetupFlowManager, "syncTabs") + .returns(Promise.resolve(null)); + sandbox.stub(TabsSetupFlowManager, "startFullTabsSync").returns(undefined); + + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + info("waiting for the error setup step to be visible"); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Enter your Primary Password") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("password-locked"), + "Correct error message is shown" + ); + + const errorLink = document.querySelector("#error-state-link"); + ok( + errorLink && BrowserTestUtils.is_visible(errorLink), + "Error link is visible" + ); + ok( + errorLink.getAttribute("data-l10n-id").includes("password-locked-link"), + "Correct link text is shown" + ); + + const primaryButton = document.querySelector("#error-state-button"); + ok( + primaryButton && BrowserTestUtils.is_visible(primaryButton), + "Error primary button is visible" + ); + + const clearErrorStub = sandbox.stub( + TabsSetupFlowManager, + "tryToClearError" + ); + info("Setup state:" + TabsSetupFlowManager.currentSetupState.name); + + info("clicking the error panel button"); + primaryButton.click(); + ok( + clearErrorStub.called, + "tryToClearError was called when the try-again button was clicked" + ); + TabsSetupFlowManager.tryToClearError.restore(); + + info("Clearing the primary password"); + LoginTestUtils.primaryPassword.disable(); + ok( + !TabsSetupFlowManager.isPrimaryPasswordLocked, + "primary password is unlocked" + ); + + info("notifying of the primary-password unlock"); + const clearErrorSpy = sandbox.spy(TabsSetupFlowManager, "tryToClearError"); + // we stubbed out sync, so pretend it ran. + info("notifying of sync:finish"); + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + + const setupContainer = document.querySelector(".sync-setup-container"); + // wait until the setup container gets hidden before checking if the tabs container is visible + // as it may not exist until then + let setupHiddenPromise = BrowserTestUtils.waitForMutationCondition( + setupContainer, + { + attributeFilter: ["hidden"], + }, + () => { + return BrowserTestUtils.is_hidden(setupContainer); + } + ); + + Services.obs.notifyObservers(null, "passwordmgr-crypto-login"); + await setupHiddenPromise; + ok( + clearErrorSpy.called, + "tryToClearError was called when the primary-password unlock notification was received" + ); + // We expect the waiting state until we get a sync update/finished + info("Setup state:" + TabsSetupFlowManager.currentSetupState.name); + + ok(TabsSetupFlowManager.waitingForTabs, "Now waiting for tabs"); + ok( + document + .querySelector("#tabpickup-tabs-container") + .classList.contains("loading"), + "Synced tabs container has loading class" + ); + + info("notifying of sync:finish"); + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + await TestUtils.waitForTick(); + ok( + !document + .querySelector("#tabpickup-tabs-container") + .classList.contains("loading"), + "Synced tabs isn't loading any more" + ); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_state.js b/browser/components/firefoxview/tests/browser/browser_setup_state.js new file mode 100644 index 0000000000..2e4921b4bf --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_state.js @@ -0,0 +1,769 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const FXA_CONTINUE_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "fxa_continue", "sync", undefined], +]; + +const FXA_MOBILE_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "fxa_mobile", "sync", undefined, { has_devices: "false" }], +]; + +var gMockFxaDevices = null; +var gUIStateStatus; + +function promiseSyncReady() { + let service = Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports) + .wrappedJSObject; + return service.whenLoaded(); +} + +var gSandbox; + +async function setupWithDesktopDevices() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "desktop", + }, + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", true]], + }); + return sandbox; +} +add_setup(async function() { + registerCleanupFunction(() => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + registerCleanupFunction(async function() { + Services.prefs.clearUserPref("services.sync.engine.tabs"); + await tearDown(gSandbox); + }); + // set tab sync false so we don't skip setup states + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", false]], + }); +}); + +add_task(async function test_unconfigured_initial_state() { + await clearAllParentTelemetryEvents(); + // test with the pref set to show FEATURE TOUR CALLOUT + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.firefox-view.feature-tour", + JSON.stringify({ + screen: `FEATURE_CALLOUT_1`, + complete: false, + }), + ], + ], + }); + const sandbox = setupMocks({ + state: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view1", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + 'button[data-action="view1-primary-action"]', + {}, + browser + ); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and fxa_continue firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + FXA_CONTINUE_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_signed_in() { + await clearAllParentTelemetryEvents(); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + ], + }); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view2", + }); + is( + fxAccounts.device.recentDeviceList?.length, + 1, + "Just 1 device connected" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + 'button[data-action="view2-primary-action"]', + {}, + browser + ); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and fxa_mobile firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + FXA_MOBILE_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_support_links() { + await clearAllParentTelemetryEvents(); + setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + ], + }); + await withFirefoxView({ win: window }, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view2", + }); + const { document } = browser.contentWindow; + const container = document.getElementById("tab-pickup-container"); + const supportLinks = Array.from( + container.querySelectorAll("a[href]") + ).filter(a => !!a.href); + is(supportLinks.length, 2, "Support links have non-empty hrefs"); + }); +}); + +add_task(async function test_2nd_desktop_connected() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "desktop", + }, + ], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.every( + device => device.type !== "mobile" && device.type !== "tablet" + ), + "No connected device is type:mobile or type:tablet" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_connected() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "mobile", + }, + ], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "mobile" + ), + "A connected device is type:mobile" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tablet_connected() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "tablet", + }, + ], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "tablet" + ), + "A connected device is type:tablet" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tab_sync_enabled() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "mobile", + }, + ], + }); + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + // test initial state, with the pref not enabled + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + // test with the pref toggled on + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", true]], + }); + await waitForElementVisible(browser, "#tabpickup-steps", false); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + // reset and test clicking the action button + await SpecialPowers.popPrefEnv(); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + const actionButton = browser.contentWindow.document.querySelector( + "#tabpickup-steps-view3 button.primary" + ); + actionButton.click(); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + await waitForElementVisible(browser, ".featureCallout .FEATURE_CALLOUT_1"); + ok(true, "Tab pickup product tour screen renders when sync is enabled"); + ok( + Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "tab sync pref should be enabled after button click" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_promo() { + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + // ensure last tab fetch was just now so we don't get the loading state + await touchLastTabFetch(); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForElementVisible(browser, ".synced-tabs-container"); + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + + info("checking mobile promo, should be visible now"); + checkMobilePromo(browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + gMockFxaDevices.push({ + id: 3, + name: "Mobile Device", + type: "mobile", + }); + + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + + // Wait for the async refreshDeviceList(), + // which should result in the promo being hidden + await waitForElementVisible( + browser, + "#tab-pickup-container > .promo-box", + false + ); + is(fxAccounts.device.recentDeviceList?.length, 3, "3 devices connected"); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: true, + }); + + info("checking mobile promo disappears on log out"); + gMockFxaDevices.pop(); + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + await waitForElementVisible( + browser, + "#tab-pickup-container > .promo-box", + true + ); + checkMobilePromo(browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + // Set the UIState to what we expect when the user signs out + gUIStateStatus = UIState.STATUS_NOT_CONFIGURED; + gUIStateSyncEnabled = undefined; + + info( + "notifying that we've signed out of fxa, UIState.get().status:" + + UIState.get().status + ); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + info("waiting for setup card 1 to appear again"); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view1", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_promo_pref() { + const sandbox = await setupWithDesktopDevices(); + await SpecialPowers.pushPrefEnv({ + set: [[MOBILE_PROMO_DISMISSED_PREF, true]], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + info("starting test, will notify of UIState update"); + // ensure last tab fetch was just now so we don't get the loading state + await touchLastTabFetch(); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForElementVisible(browser, ".synced-tabs-container"); + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + + info("checking mobile promo, should be still hidden because of the pref"); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + // reset the dismissed pref, which should case the promo to get shown + await SpecialPowers.popPrefEnv(); + await waitForElementVisible( + browser, + "#tab-pickup-container > .promo-box", + true + ); + + const promoElem = browser.contentWindow.document.querySelector( + "#tab-pickup-container > .promo-box" + ); + const promoElemClose = promoElem.querySelector(".close"); + ok(promoElemClose.hasAttribute("aria-label"), "Button has an a11y name"); + // check that dismissing the promo sets the pref + info("Clicking the promo close button: " + promoElemClose); + EventUtils.sendMouseEvent({ type: "click" }, promoElemClose); + + info("Check the promo box got hidden"); + BrowserTestUtils.is_hidden(promoElem); + ok( + SpecialPowers.getBoolPref(MOBILE_PROMO_DISMISSED_PREF), + "Promo pref is updated when close is clicked" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_promo_windows() { + // make sure interacting with the promo and success confirmation in one window + // also updates the others + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + // ensure last tab fetch was just now so we don't get the loading state + await touchLastTabFetch(); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForElementVisible(browser, ".synced-tabs-container"); + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + + info("checking mobile promo is visible"); + checkMobilePromo(browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + info( + "opening new window, pref is: " + + SpecialPowers.getBoolPref("browser.tabs.firefox-view") + ); + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + info("Got window, now opening Firefox View in it"); + await withFirefoxView( + { resetFlowManager: false, win: win2 }, + async win2Browser => { + info("In withFirefoxView taskFn for win2"); + // promo should be visible in the 2nd window too + info("check mobile promo is visible in the new window"); + checkMobilePromo(win2Browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + // add the mobile device to get the success confirmation in both instances + info("add a mobile device and send device_connected notification"); + gMockFxaDevices.push({ + id: 3, + name: "Mobile Device", + type: "mobile", + }); + + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + is( + fxAccounts.device.recentDeviceList?.length, + 3, + "3 devices connected" + ); + + // Wait for the async refreshDevices(), + // which should result in the promo being hidden + info("waiting for the confirmation box to be visible"); + await waitForElementVisible( + win2Browser, + "#tab-pickup-container > .promo-box", + false + ); + + for (let fxviewBrowser of [browser, win2Browser]) { + info( + "checking promo is hidden and confirmation is visible in each window" + ); + checkMobilePromo(fxviewBrowser, { + mobilePromo: false, + mobileConfirmation: true, + }); + } + + // dismiss the confirmation and check its gone from both instances + const confirmBox = win2Browser.contentWindow.document.querySelector( + "#tab-pickup-container > .confirmation-message-box" + ); + const closeButton = confirmBox.querySelector(".close"); + ok(closeButton.hasAttribute("aria-label"), "Button has an a11y name"); + EventUtils.sendMouseEvent({ type: "click" }, closeButton, win2); + BrowserTestUtils.is_hidden(confirmBox); + + for (let fxviewBrowser of [browser, win2Browser]) { + checkMobilePromo(fxviewBrowser, { + mobilePromo: false, + mobileConfirmation: false, + }); + } + } + ); + await BrowserTestUtils.closeWindow(win2); + }); + await tearDown(sandbox); +}); + +async function mockFxaDeviceConnected(win) { + // We use an existing tab to navigate to the final "device connected" url + // in order to fake the fxa device sync process + const url = "https://example.org/pair/auth/complete"; + is(win.gBrowser.tabs.length, 3, "Tabs strip should contain three tabs"); + + BrowserTestUtils.loadURI(win.gBrowser.selectedTab.linkedBrowser, url); + + await BrowserTestUtils.browserLoaded( + win.gBrowser.selectedTab.linkedBrowser, + null, + url + ); + + is( + win.gBrowser.selectedTab.linkedBrowser.currentURI.filePath, + "/pair/auth/complete", + "/pair/auth/complete is the selected tab" + ); +} + +add_task(async function test_close_device_connected_tab() { + // test that when a device has been connected to sync we close + // that tab after the user is directed back to firefox view + + // Ensure we are in the correct state to start the task. + TabsSetupFlowManager.resetInternalState(); + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.remote.root", "https://example.org/"]], + }); + let win = await BrowserTestUtils.openNewBrowserWindow(); + let fxViewTab = await openFirefoxViewTab(win); + + await waitForVisibleSetupStep(win.gBrowser, { + expectedVisible: "#tabpickup-steps-view1", + }); + + let actionButton = win.gBrowser.contentWindow.document.querySelector( + "#tabpickup-steps-view1 button.primary" + ); + // initiate the sign in flow from Firefox View, to check that didFxaTabOpen is set + let tabSwitched = BrowserTestUtils.waitForEvent( + win.gBrowser, + "TabSwitchDone" + ); + actionButton.click(); + await tabSwitched; + + // fake the end point of the device syncing flow + await mockFxaDeviceConnected(win); + let deviceConnectedTab = win.gBrowser.tabs[2]; + + // remove the blank tab opened with the browser to check that we don't + // close the window when the "Device connected" tab is closed + const newTab = win.gBrowser.tabs.find( + tab => tab != deviceConnectedTab && tab != fxViewTab + ); + let removedTab = BrowserTestUtils.waitForTabClosing(newTab); + BrowserTestUtils.removeTab(newTab); + await removedTab; + + is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs"); + + is( + win.gBrowser.selectedTab.linkedBrowser.currentURI.filePath, + "/pair/auth/complete", + "/pair/auth/complete is the selected tab" + ); + + // we use this instead of BrowserTestUtils.switchTab to get back to the firefox view tab + // because this more accurately reflects how this tab is selected - via a custom onmousedown + // and command that calls FirefoxViewHandler.openTab (both when the user manually clicks the tab + // and when navigating from the fxa Device Connected tab, which also calls FirefoxViewHandler.openTab) + await EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("firefox-view-button"), + { type: "mousedown" }, + win + ); + + is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs"); + + is( + win.gBrowser.tabs[0].linkedBrowser.currentURI.filePath, + "firefoxview", + "First tab is Firefox view" + ); + + is( + win.gBrowser.tabs[1].linkedBrowser.currentURI.filePath, + "newtab", + "Second tab is about:newtab" + ); + + // now simulate the signed-in state with the prompt to download + // and sync mobile + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + ], + }); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForVisibleSetupStep(win.gBrowser, { + expectedVisible: "#tabpickup-steps-view2", + }); + + actionButton = win.gBrowser.contentWindow.document.querySelector( + "#tabpickup-steps-view2 button.primary" + ); + // initiate the connect device (mobile) flow from Firefox View, to check that didFxaTabOpen is set + tabSwitched = BrowserTestUtils.waitForEvent(win.gBrowser, "TabSwitchDone"); + actionButton.click(); + await tabSwitched; + // fake the end point of the device syncing flow + await mockFxaDeviceConnected(win); + + await EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("firefox-view-button"), + { type: "mousedown" }, + win + ); + is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs"); + + is( + win.gBrowser.tabs[0].linkedBrowser.currentURI.filePath, + "firefoxview", + "First tab is Firefox view" + ); + + is( + win.gBrowser.tabs[1].linkedBrowser.currentURI.filePath, + "newtab", + "Second tab is about:newtab" + ); + + // cleanup time + await tearDown(sandbox); + await SpecialPowers.popPrefEnv(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js b/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js new file mode 100644 index 0000000000..63518e79c0 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); +} + +function checkLoadingState(browser, isLoading = false) { + const { document } = browser.contentWindow; + const tabsContainer = document.querySelector("#tabpickup-tabs-container"); + const tabsList = document.querySelector( + "#tabpickup-tabs-container tab-pickup-list" + ); + const loadingElem = document.querySelector( + "#tabpickup-tabs-container .loading-content" + ); + const setupElem = document.querySelector("#tabpickup-steps"); + + if (isLoading) { + ok( + tabsContainer.classList.contains("loading"), + "Tabs container has loading class" + ); + BrowserTestUtils.is_visible( + loadingElem, + "Loading content is visible when loading" + ); + !tabsList || + BrowserTestUtils.is_hidden( + tabsList, + "Synced tabs list is not visible when loading" + ); + !setupElem || + BrowserTestUtils.is_hidden( + setupElem, + "Setup content is not visible when loading" + ); + } else { + ok( + !tabsContainer.classList.contains("loading"), + "Tabs container has no loading class" + ); + !loadingElem || + BrowserTestUtils.is_hidden( + loadingElem, + "Loading content is not visible when tabs are loaded" + ); + BrowserTestUtils.is_visible( + tabsList, + "Synced tabs list is visible when loaded" + ); + !setupElem || + BrowserTestUtils.is_hidden( + setupElem, + "Setup content is not visible when tabs are loaded" + ); + } +} + +function setupMocks(recentTabs, syncEnabled = true) { + const sandbox = (gSandbox = setupRecentDeviceListMocks()); + sandbox.stub(SyncedTabs, "getRecentTabs").callsFake(() => { + info( + `SyncedTabs.getRecentTabs will return a promise resolving to ${recentTabs.length} tabs` + ); + return Promise.resolve(recentTabs); + }); + return sandbox; +} + +add_setup(async function() { + registerCleanupFunction(() => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + registerCleanupFunction(async function() { + Services.prefs.clearUserPref("services.sync.engine.tabs"); + await tearDown(gSandbox); + }); +}); + +add_task(async function test_tab_sync_loading() { + // empty synced tabs, so we're relying on tabs.changed or sync:finish notifications to clear the waiting state + const recentTabsData = []; + const sandbox = setupMocks(recentTabsData); + // stub syncTabs so it resolves to true - meaning yes it will trigger a sync, which is the case + // we want to cover in this test. + sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(true); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + const { document } = browser.contentWindow; + const tabsContainer = document.querySelector("#tabpickup-tabs-container"); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + ok(TabsSetupFlowManager.waitingForTabs, "waitingForTabs is true"); + checkLoadingState(browser, true); + + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + await BrowserTestUtils.waitForMutationCondition( + tabsContainer, + { attributeFilter: ["class"], attributes: true }, + () => { + return !tabsContainer.classList.contains("loading"); + } + ); + checkLoadingState(browser, false); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tab_no_sync() { + // Ensure we take down the waiting message if SyncedTabs determines it doesnt need to sync + const recentTabsData = []; + const sandbox = setupMocks(recentTabsData); + // stub syncTabs so it resolves to false - meaning it will not trigger a sync, which is the case + // we want to cover in this test. + sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(false); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + + ok(!TabsSetupFlowManager.waitingForTabs, "waitingForTabs is false"); + checkLoadingState(browser, false); + }); + await tearDown(sandbox); +}); + +add_task(async function test_recent_tabs_loading() { + // Simulate stale data by setting lastTabFetch to 10mins ago + const TEN_MINUTES_MS = 1000 * 60 * 10; + const staleFetchSeconds = Math.floor((Date.now() - TEN_MINUTES_MS) / 1000); + info("updating lastFetch:" + staleFetchSeconds); + Services.prefs.setIntPref("services.sync.lastTabFetch", staleFetchSeconds); + + // cached tabs data is available, so we shouldn't wait on lastTabFetch pref value + const recentTabsData = structuredClone(syncedTabsData1[0].tabs); + const sandbox = setupMocks(recentTabsData); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + checkLoadingState(browser, false); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js b/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js new file mode 100644 index 0000000000..43c0663d76 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +var gSandbox; + +add_setup(async function() { + Services.prefs.lockPref("identity.fxaccounts.enabled"); + + registerCleanupFunction(() => { + gSandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.unlockPref("identity.fxaccounts.enabled"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); +}); + +add_task(async function test_sync_admin_disabled() { + const sandbox = (gSandbox = sinon.createSandbox()); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }; + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + is( + Services.prefs.getBoolPref("identity.fxaccounts.enabled"), + true, + "Expected identity.fxaccounts.enabled pref to be false" + ); + + is( + Services.prefs.prefIsLocked("identity.fxaccounts.enabled"), + true, + "Expected identity.fxaccounts.enabled pref to be locked" + ); + + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("disabled") + ); + + ok( + errorStateHeader + .getAttribute("data-l10n-id") + .includes("fxa-admin-disabled"), + "Correct message should show when fxa is disabled by an admin" + ); + }); + Services.prefs.unlockPref("identity.fxaccounts.enabled"); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js new file mode 100644 index 0000000000..4af862d40c --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "https://example.com/"; + +add_task(async function closing_last_tab_should_not_switch_to_fx_view() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.closeWindowWithLastTab", false]], + }); + info("Opening window..."); + const win = await BrowserTestUtils.openNewBrowserWindow({ + waitForTabURL: "about:newtab", + }); + const firstTab = win.gBrowser.selectedTab; + info("Opening Firefox View tab..."); + await openFirefoxViewTab(win); + info("Switch back to new tab..."); + await BrowserTestUtils.switchTab(win.gBrowser, firstTab); + info("Load web page in new tab..."); + const loaded = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + URL + ); + BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, URL); + await loaded; + info("Opening new browser tab..."); + const secondTab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + URL + ); + info("Close all broswer tabs..."); + await BrowserTestUtils.removeTab(firstTab); + await BrowserTestUtils.removeTab(secondTab); + isnot( + win.gBrowser.selectedTab, + win.FirefoxViewHandler.tab, + "The selected tab should not be the Firefox View tab" + ); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js new file mode 100644 index 0000000000..cd6d30f3d1 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +class DialogObserver { + constructor() { + this.wasOpened = false; + Services.obs.addObserver(this, "common-dialog-loaded"); + } + cleanup() { + Services.obs.removeObserver(this, "common-dialog-loaded"); + } + observe(win, topic) { + if (topic == "common-dialog-loaded") { + this.wasOpened = true; + // Close dialog. + win.document + .querySelector("dialog") + .getButton("cancel") + .click(); + } + } +} + +add_task( + async function on_close_warning_should_not_show_for_firefox_view_tab() { + const dialogObserver = new DialogObserver(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.warnOnClose", true]], + }); + info("Opening window..."); + const win = await BrowserTestUtils.openNewBrowserWindow(); + info("Opening Firefox View tab..."); + await openFirefoxViewTab(win); + info("Trigger warnAboutClosingWindow()"); + win.BrowserTryToCloseWindow(); + await BrowserTestUtils.closeWindow(win); + ok(!dialogObserver.wasOpened, "Dialog was not opened"); + dialogObserver.cleanup(); + } +); + +add_task( + async function on_close_warning_should_not_show_for_firefox_view_tab_non_macos() { + let initialTab = gBrowser.selectedTab; + const dialogObserver = new DialogObserver(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.warnOnClose", true], + ["browser.warnOnQuit", true], + ], + }); + info("Opening Firefox View tab..."); + await openFirefoxViewTab(window); + info('Trigger "quit-application-requested"'); + canQuitApplication("lastwindow", "close-button"); + ok(!dialogObserver.wasOpened, "Dialog was not opened"); + await BrowserTestUtils.switchTab(gBrowser, initialTab); + closeFirefoxViewTab(window); + dialogObserver.cleanup(); + } +); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js new file mode 100644 index 0000000000..b7fa3a2e5a --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js @@ -0,0 +1,607 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +const twoTabs = [ + { + type: "tab", + title: "Phabricator Home", + url: "https://phabricator.services.mozilla.com/", + icon: "https://phabricator.services.mozilla.com/favicon.d25d81d39065.ico", + lastUsed: 1655745700, // Mon, 20 Jun 2022 17:21:40 GMT + }, + { + type: "tab", + title: "Firefox Privacy Notice", + url: "https://www.mozilla.org/en-US/privacy/firefox/", + icon: + "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + lastUsed: 1655745700, // Mon, 20 Jun 2022 17:21:40 GMT + }, +]; +const syncedTabsData2 = structuredClone(syncedTabsData1); +syncedTabsData2[1].tabs = [...syncedTabsData2[1].tabs, ...twoTabs]; + +const syncedTabsData3 = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + lastModified: 1655730486760, + tabs: [ + { + type: "tab", + title: "Sandboxes - Sinon.JS", + url: "https://sinonjs.org/releases/latest/sandbox/", + icon: "https://sinonjs.org/assets/images/favicon.png", + lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000 + }, + ], + }, +]; + +const syncedTabsData4 = structuredClone(syncedTabsData3); +syncedTabsData4[0].tabs = [...syncedTabsData4[0].tabs, ...twoTabs]; + +const syncedTabsData5 = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + lastModified: Date.now(), + tabs: [ + { + type: "tab", + title: "Example2", + url: "https://example.com", + icon: "https://example/favicon.png", + lastUsed: Math.floor((Date.now() - 1000 * 60) / 1000), // This is one minute from now, which is below the threshold for 'Just now' + }, + ], + }, +]; + +const NO_TABS_EVENTS = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "synced_tabs", "tabs", undefined, { count: "0" }], +]; + +const TAB_PICKUP_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "synced_tabs", "tabs", undefined, { count: "1" }], + [ + "firefoxview", + "tab_pickup", + "tabs", + undefined, + { position: "1", deviceType: "desktop" }, + ], +]; + +const TAB_PICKUP_OPEN_EVENT = [ + ["firefoxview", "tab_pickup_open", "tabs", "false"], +]; + +registerCleanupFunction(async function() { + cleanup_tab_pickup(); +}); + +add_task(async function test_tab_list_ordering() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let mockTabs2 = getMockTabData(syncedTabsData2); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + + testVisibility(browser, { + expectedVisible: { + "ol.synced-tabs-list": true, + }, + }); + + ok( + document.querySelector("ol.synced-tabs-list").children.length === 3, + "synced-tabs-list should have three list items" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .firstChild.textContent.includes("Internet for people, not profits"), + "First list item in synced-tabs-list is in the correct order" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[2].textContent.includes("Sandboxes - Sinon.JS"), + "Last list item in synced-tabs-list is in the correct order" + ); + + syncedTabsMock.returns(mockTabs2); + // Initiate a synced tabs update + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + // first list item has been updated + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => syncedTabsList.firstChild.textContent.includes("Firefox") + ); + + ok( + document.querySelector("ol.synced-tabs-list").children.length === 3, + "Synced-tabs-list should still have three list items" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[1].textContent.includes("Phabricator"), + "Second list item in synced-tabs-list has been updated" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[2].textContent.includes("Internet for people, not profits"), + "Last list item in synced-tabs-list has been updated" + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_empty_list_items() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData3); + let mockTabs2 = getMockTabData(syncedTabsData4); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + + testVisibility(browser, { + expectedVisible: { + "ol.synced-tabs-list": true, + }, + }); + + ok( + document.querySelector("ol.synced-tabs-list").children.length === 3, + "synced-tabs-list should have three list items" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .firstChild.textContent.includes("Sandboxes - Sinon.JS"), + "First list item in synced-tabs-list is in the correct order" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[1].classList.contains("synced-tab-li-placeholder"), + "Second list item in synced-tabs-list should be a placeholder" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .lastChild.classList.contains("synced-tab-li-placeholder"), + "Last list item in synced-tabs-list should be a placeholder" + ); + + syncedTabsMock.returns(mockTabs2); + // Initiate a synced tabs update + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + // first list item has been updated + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => + syncedTabsList.firstChild.textContent.includes("Firefox Privacy Notice") + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[1].textContent.includes("Phabricator"), + "Second list item in synced-tabs-list has been updated" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .lastChild.textContent.includes("Sandboxes - Sinon.JS"), + "Last list item in synced-tabs-list has been updated" + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_empty_list() { + await clearAllParentTelemetryEvents(); + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData([]); + let mockTabs2 = getMockTabData(syncedTabsData4); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + info("setupListState complete, checking placeholder and list visibility"); + testVisibility(browser, { + expectedVisible: { + "#synced-tabs-placeholder": true, + "ol.synced-tabs-list": false, + }, + }); + + ok( + document + .querySelector("#synced-tabs-placeholder") + .classList.contains("empty-container"), + "collapsible container should have correct styling when the list is empty" + ); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and synced_tabs firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + NO_TABS_EVENTS, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs2.length} tabs\n` + ); + return Promise.resolve(mockTabs2); + }); + // Initiate a synced tabs update + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => syncedTabsList.children.length + ); + + testVisibility(browser, { + expectedVisible: { + "#synced-tabs-placeholder": false, + "ol.synced-tabs-list": true, + }, + }); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_time_updates_correctly() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", 100]], + }); + await clearAllParentTelemetryEvents(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData5); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + + let initialTimeText = document.querySelector("span.synced-tab-li-time") + .textContent; + Assert.stringContains( + initialTimeText, + "Just now", + "synced-tab-li-time text is 'Just now'" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", 100]], + }); + + const timeLabel = document.querySelector("span.synced-tab-li-time"); + await BrowserTestUtils.waitForMutationCondition( + timeLabel, + { childList: true }, + () => !timeLabel.textContent.includes("now") + ); + + isnot( + timeLabel.textContent, + initialTimeText, + "synced-tab-li-time text has updated" + ); + + document.querySelector(".synced-tab-a").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 3; + }, + "Waiting for entered, synced_tabs, and tab_pickup firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + TAB_PICKUP_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + let gBrowser = browser.getTabBrowser(); + is( + gBrowser.visibleTabs.indexOf(gBrowser.selectedTab), + 0, + "Tab opened at the beginning of the tab strip" + ); + gBrowser.removeTab(gBrowser.selectedTab); + // make sure we're back on fx-view + browser.ownerGlobal.FirefoxViewHandler.openTab(); + + info("Waiting for the tab pickup summary to be visible"); + await waitForElementVisible(browser, "#tab-pickup-container > summary"); + // click on the details summary and verify telemetry gets logged for this event + await clearAllParentTelemetryEvents(); + info("clicking the summary to collapse it"); + document.querySelector("#tab-pickup-container > summary").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for tab_pickup_open firefoxview telemetry event.", + 200, + 100 + ); + TelemetryTestUtils.assertEvents( + TAB_PICKUP_OPEN_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + sandbox.restore(); + cleanup_tab_pickup(); + await SpecialPowers.popPrefEnv(); + }); +}); + +/** + * Ensure that tabs sync when a user reloads Firefox View. + * This is accomplished by asserting that a new set of tabs are loaded + * on page reload. + */ +add_task(async function test_tabs_sync_on_user_page_reload() { + const sandbox = setupRecentDeviceListMocks(); + sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(true); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let expectedTabsAfterReload = getMockTabData(syncedTabsData3); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + let reloadButton = browser.ownerDocument.getElementById("reload-button"); + + await setupListState(browser); + + let tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}, browser.ownerGlobal); + await tabLoaded; + // Wait until the window is reloaded, then get the current instance + // of the contentWindow + const { document } = browser.contentWindow; + ok(true, "Firefox View has been reloaded"); + ok(TabsSetupFlowManager.waitingForTabs, "waitingForTabs is true"); + + syncedTabsMock.returns(expectedTabsAfterReload); + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + ok(!TabsSetupFlowManager.waitingForTabs, "waitingForTabs is false"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + // The tab pickup list has been updated + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => + syncedTabsList.firstChild.textContent.includes("Sandboxes - Sinon.JS") + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_keyboard_navigation() { + // Setting this pref allows the test to run as expected on MacOS + await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }); + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + + await setupListState(browser); + const tab = (shiftKey = false) => { + info(`${shiftKey ? "Shift + Tab" : "Tab"}`); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey }, win); + }; + const arrowDown = () => { + info("Arrow Down"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }; + const arrowUp = () => { + info("Arrow Up"); + EventUtils.synthesizeKey("KEY_ArrowUp", {}, win); + }; + const arrowLeft = () => { + info("Arrow Left"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + }; + const arrowRight = () => { + info("Arrow Right"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + }; + + let syncedTabsLinks = document + .querySelector("ol.synced-tabs-list") + .querySelectorAll("a"); + let summary = document + .getElementById("tab-pickup-container") + .querySelector("summary"); + summary.focus(); + tab(); + is( + syncedTabsLinks[0], + document.activeElement, + "First synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[1], + document.activeElement, + "Second synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[2], + document.activeElement, + "Third synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[2], + document.activeElement, + "Third synced tab should still be focused" + ); + arrowUp(); + is( + syncedTabsLinks[1], + document.activeElement, + "Second synced tab should be focused" + ); + arrowLeft(); + is( + syncedTabsLinks[0], + document.activeElement, + "First synced tab should be focused" + ); + arrowRight(); + is( + syncedTabsLinks[1], + document.activeElement, + "Second synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[2], + document.activeElement, + "Third synced tab should be focused" + ); + arrowLeft(); + is( + syncedTabsLinks[0], + document.activeElement, + "First synced tab should be focused" + ); + + tab(true); + is( + summary, + document.activeElement, + "Summary element should be focused when shift tabbing away from list" + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_ui_state.js b/browser/components/firefoxview/tests/browser/browser_ui_state.js new file mode 100644 index 0000000000..cc19b75023 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_ui_state.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +add_task(async function test_state_prefs_unset() { + await SpecialPowers.clearUserPref(TAB_PICKUP_STATE_PREF); + await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + let recentlyClosedTabsContainer = document.querySelector( + "#recently-closed-tabs-container" + ); + ok( + recentlyClosedTabsContainer.open, + "Recently Closed Tabs should be open if the pref is unset and sync setup is complete" + ); + + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + ok( + tabPickupContainer.open, + "Tab Pickup container should be open if the pref is unset and sync setup is complete" + ); + + sandbox.restore(); + }); +}); + +add_task(async function test_state_prefs_defined() { + await SpecialPowers.pushPrefEnv({ + set: [ + [TAB_PICKUP_STATE_PREF, false], + [RECENTLY_CLOSED_STATE_PREF, false], + ], + }); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + let recentlyClosedTabsContainer = document.querySelector( + "#recently-closed-tabs-container" + ); + ok( + !recentlyClosedTabsContainer.getAttribute("open"), + "Recently Closed Tabs should not be open if the pref is set to false" + ); + + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + ok( + !tabPickupContainer.getAttribute("open"), + "Tab Pickup container should not be open if the pref is set to false and sync setup is complete" + ); + + sandbox.restore(); + }); +}); + +add_task(async function test_state_pref_set_on_toggle() { + await SpecialPowers.pushPrefEnv({ + set: [ + [TAB_PICKUP_STATE_PREF, true], + [RECENTLY_CLOSED_STATE_PREF, true], + ], + }); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + + await waitForElementVisible(browser, "#tab-pickup-container > summary"); + + document.querySelector("#tab-pickup-container > summary").click(); + + document.querySelector("#recently-closed-tabs-container > summary").click(); + + // Wait a turn for the click to propagate to the pref. + await TestUtils.waitForTick(); + + ok( + !Services.prefs.getBoolPref(RECENTLY_CLOSED_STATE_PREF), + "Hiding the recently closed container should have flipped the UI state pref value" + ); + ok( + !Services.prefs.getBoolPref(TAB_PICKUP_STATE_PREF), + "Hiding the tab pickup container should have flipped the UI state pref value" + ); + + sandbox.restore(); + }); +}); + +add_task(async function test_state_prefs_ignored_during_sync_setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + [TAB_PICKUP_STATE_PREF, false], + [RECENTLY_CLOSED_STATE_PREF, false], + ], + }); + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(false); + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + let recentlyClosedTabsContainer = document.querySelector( + "#recently-closed-tabs-container" + ); + ok( + !recentlyClosedTabsContainer.open, + "Recently Closed Tabs should not be open if the pref is set to false" + ); + + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + ok( + tabPickupContainer.open, + "Tab Pickup container should be open if the pref is set to false but sync setup is not complete" + ); + + sandbox.restore(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/head.js b/browser/components/firefoxview/tests/browser/head.js new file mode 100644 index 0000000000..68bf9b8316 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/head.js @@ -0,0 +1,599 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* exported testVisibility */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { ASRouter } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); +const { UIState } = ChromeUtils.import("resource://services-sync/UIState.jsm"); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); +const { FeatureCalloutMessages } = ChromeUtils.import( + "resource://activity-stream/lib/FeatureCalloutMessages.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm", +}); + +const MOBILE_PROMO_DISMISSED_PREF = + "browser.tabs.firefox-view.mobilePromo.dismissed"; +const RECENTLY_CLOSED_STATE_PREF = + "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open"; +const TAB_PICKUP_STATE_PREF = + "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +const calloutId = "root"; +const calloutSelector = `#${calloutId}.featureCallout`; +const primaryButtonSelector = `#${calloutId} .primary`; + +/** + * URLs used for browser_recently_closed_tabs_keyboard and + * browser_firefoxview_accessibility + */ +const URLs = [ + "http://mochi.test:8888/browser/", + "https://www.example.com/", + "https://example.net/", + "https://example.org/", +]; + +const syncedTabsData1 = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + lastModified: 1655730486760, + tabs: [ + { + type: "tab", + title: "Sandboxes - Sinon.JS", + url: "https://sinonjs.org/releases/latest/sandbox/", + icon: "https://sinonjs.org/assets/images/favicon.png", + lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000 + }, + { + type: "tab", + title: "Internet for people, not profits - Mozilla", + url: "https://www.mozilla.org/", + icon: + "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + lastUsed: 1655730486, // Mon Jun 20 2022 13:08:06 GMT+0000 + }, + ], + }, + { + id: 2, + type: "client", + name: "My iphone", + clientType: "phone", + lastModified: 1655727832930, + tabs: [ + { + type: "tab", + title: "The Guardian", + url: "https://www.theguardian.com/", + icon: "page-icon:https://www.theguardian.com/", + lastUsed: 1655291890, // Wed Jun 15 2022 11:18:10 GMT+0000 + }, + { + type: "tab", + title: "The Times", + url: "https://www.thetimes.co.uk/", + icon: "page-icon:https://www.thetimes.co.uk/", + lastUsed: 1655727485, // Mon Jun 20 2022 12:18:05 GMT+0000 + }, + ], + }, +]; + +async function clearAllParentTelemetryEvents() { + // Clear everything. + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + return !events || !events.length; + }); +} + +function testVisibility(browser, expected) { + const { document } = browser.contentWindow; + for (let [selector, shouldBeVisible] of Object.entries( + expected.expectedVisible + )) { + const elem = document.querySelector(selector); + if (shouldBeVisible) { + ok( + BrowserTestUtils.is_visible(elem), + `Expected ${selector} to be visible` + ); + } else { + ok(BrowserTestUtils.is_hidden(elem), `Expected ${selector} to be hidden`); + } + } +} + +async function waitForElementVisible(browser, selector, isVisible = true) { + const { document } = browser.contentWindow; + const elem = document.querySelector(selector); + if (!isVisible && !elem) { + return; + } + ok(elem, `Got element with selector: ${selector}`); + + await BrowserTestUtils.waitForMutationCondition( + elem, + { + attributeFilter: ["hidden"], + }, + () => { + return isVisible + ? BrowserTestUtils.is_visible(elem) + : BrowserTestUtils.is_hidden(elem); + } + ); +} + +async function waitForVisibleSetupStep(browser, expected) { + const { document } = browser.contentWindow; + + const deck = document.querySelector(".sync-setup-container"); + const nextStepElem = deck.querySelector(expected.expectedVisible); + const stepElems = deck.querySelectorAll(".setup-step"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { + attributeFilter: ["selected-view"], + }, + () => { + return BrowserTestUtils.is_visible(nextStepElem); + } + ); + + for (let elem of stepElems) { + if (elem == nextStepElem) { + ok( + BrowserTestUtils.is_visible(elem), + `Expected ${elem.id || elem.className} to be visible` + ); + } else { + ok( + BrowserTestUtils.is_hidden(elem), + `Expected ${elem.id || elem.className} to be hidden` + ); + } + } +} + +function assertFirefoxViewTab(w) { + ok(w.FirefoxViewHandler.tab, "Firefox View tab exists"); + ok(w.FirefoxViewHandler.tab?.hidden, "Firefox View tab is hidden"); + is( + w.gBrowser.visibleTabs.indexOf(w.FirefoxViewHandler.tab), + -1, + "Firefox View tab is not in the list of visible tabs" + ); +} + +async function openFirefoxViewTab(w) { + ok( + !w.FirefoxViewHandler.tab, + "Firefox View tab doesn't exist prior to clicking the button" + ); + info("Clicking the Firefox View button"); + await EventUtils.synthesizeMouseAtCenter( + w.document.getElementById("firefox-view-button"), + { type: "mousedown" }, + w + ); + assertFirefoxViewTab(w); + ok(w.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + await BrowserTestUtils.browserLoaded(w.FirefoxViewHandler.tab.linkedBrowser); + return w.FirefoxViewHandler.tab; +} + +function closeFirefoxViewTab(w) { + w.gBrowser.removeTab(w.FirefoxViewHandler.tab); + ok( + !w.FirefoxViewHandler.tab, + "Reference to Firefox View tab got removed when closing the tab" + ); +} + +async function withFirefoxView( + { resetFlowManager = true, win = null }, + taskFn +) { + let shouldCloseWin = false; + if (!win) { + win = await BrowserTestUtils.openNewBrowserWindow(); + shouldCloseWin = true; + } + if (resetFlowManager) { + const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" + ); + // reset internal state so we aren't reacting to whatever state the last invocation left behind + TabsSetupFlowManager.resetInternalState(); + } + let tab = await openFirefoxViewTab(win); + let originalWindow = tab.ownerGlobal; + let result = await taskFn(tab.linkedBrowser); + let finalWindow = tab.ownerGlobal; + if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) { + // taskFn may resolve within a tick after opening a new tab. + // We shouldn't remove the newly opened tab in the same tick. + // Wait for the next tick here. + await TestUtils.waitForTick(); + BrowserTestUtils.removeTab(tab); + } else { + Services.console.logStringMessage( + "withFirefoxView: Tab was already closed before " + + "removeTab would have been called" + ); + } + if (shouldCloseWin) { + await BrowserTestUtils.closeWindow(win); + } + return result; +} + +var gMockFxaDevices = null; +var gUIStateStatus; +var gSandbox; +function setupSyncFxAMocks({ fxaDevices = null, state, syncEnabled = true }) { + gUIStateStatus = state || UIState.STATUS_SIGNED_IN; + if (gSandbox) { + gSandbox.restore(); + } + const sandbox = (gSandbox = sinon.createSandbox()); + gMockFxaDevices = fxaDevices; + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: gUIStateStatus, + syncEnabled, + email: + gUIStateStatus === UIState.STATUS_NOT_CONFIGURED + ? undefined + : "email@example.com", + }; + }); + + return sandbox; +} + +function setupRecentDeviceListMocks() { + const sandbox = sinon.createSandbox(); + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [ + { + id: 1, + name: "My desktop", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "My iphone", + type: "mobile", + }, + ]); + + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "email@example.com", + }); + + return sandbox; +} + +function getMockTabData(clients) { + let tabs = []; + + for (let client of clients) { + for (let tab of client.tabs) { + tab.device = client.name; + tab.deviceType = client.clientType; + } + tabs = [...tabs, ...client.tabs.reverse()]; + } + tabs = tabs.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, 3); + + return tabs; +} + +async function setupListState(browser) { + // Skip the synced tabs sign up flow to get to a loaded list state + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", true]], + }); + + UIState.refresh(); + const recentFetchTime = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + + const tabsContainer = browser.contentWindow.document.querySelector( + "#tabpickup-tabs-container" + ); + await tabsContainer.tabListAdded; + await BrowserTestUtils.waitForMutationCondition( + tabsContainer, + { attributeFilter: ["class"], attributes: true }, + () => { + return !tabsContainer.classList.contains("loading"); + } + ); + info("tabsContainer isn't loading anymore, returning"); +} + +function checkMobilePromo(browser, expected = {}) { + const { document } = browser.contentWindow; + const promoElem = document.querySelector( + "#tab-pickup-container > .promo-box" + ); + const successElem = document.querySelector( + "#tab-pickup-container > .confirmation-message-box" + ); + + info("checkMobilePromo: " + JSON.stringify(expected)); + if (expected.mobilePromo) { + ok(BrowserTestUtils.is_visible(promoElem), "Mobile promo is visible"); + } else { + ok( + !promoElem || BrowserTestUtils.is_hidden(promoElem), + "Mobile promo is hidden" + ); + } + if (expected.mobileConfirmation) { + ok( + BrowserTestUtils.is_visible(successElem), + "Success confirmation is visible" + ); + } else { + ok( + !successElem || BrowserTestUtils.is_hidden(successElem), + "Success confirmation is hidden" + ); + } +} + +async function touchLastTabFetch() { + // lastTabFetch stores a timestamp in *seconds*. + const nowSeconds = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + nowSeconds); + Services.prefs.setIntPref("services.sync.lastTabFetch", nowSeconds); + // wait so all pref observers can complete + await TestUtils.waitForTick(); +} + +let gUIStateSyncEnabled; +function setupMocks({ fxaDevices = null, state, syncEnabled = true }) { + gUIStateStatus = state || UIState.STATUS_SIGNED_IN; + gUIStateSyncEnabled = syncEnabled; + if (gSandbox) { + gSandbox.restore(); + } + const sandbox = (gSandbox = sinon.createSandbox()); + gMockFxaDevices = fxaDevices; + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: gUIStateStatus, + // Sometimes syncEnabled is not present on UIState, for example when the user signs + // out the state is just { status: "not_configured" } + ...(gUIStateSyncEnabled != undefined && { + syncEnabled: gUIStateSyncEnabled, + }), + }; + }); + return sandbox; +} + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF); +} + +/** + * Returns a value that can be used to set + * `browser.firefox-view.feature-tour` to change the feature tour's + * UI state. + * + * @see FeatureCalloutMessages.jsm for valid values of "screen" + * + * @param {number} screen The full ID of the feature callout screen + * @return {string} JSON string used to set + * `browser.firefox-view.feature-tour` + */ +const getPrefValueByScreen = screen => { + return JSON.stringify({ + screen: `FEATURE_CALLOUT_${screen}`, + complete: false, + }); +}; + +/** + * Wait for a feature callout screen of given parameters to be shown + * @param {Document} doc the document where the callout appears. + * @param {String} screenPostfix The full ID of the feature callout screen. + */ +const waitForCalloutScreen = async (doc, screenPostfix) => { + await BrowserTestUtils.waitForCondition(() => + doc.querySelector(`${calloutSelector}:not(.hidden) .${screenPostfix}`) + ); +}; + +/** + * Waits for the feature callout screen to be removed. + * + * @param {Document} doc The document where the callout appears. + */ +const waitForCalloutRemoved = async doc => { + await BrowserTestUtils.waitForCondition(() => { + return !doc.body.querySelector(calloutSelector); + }); +}; + +/** + * NOTE: Should be replaced with synthesizeMouseAtCenter for + * simulating user input. See Bug 1798322 + * + * Clicks the primary button in the feature callout dialog + * + * @param {document} doc Firefox View document + */ +const clickPrimaryButton = async doc => { + doc.querySelector(primaryButtonSelector).click(); +}; + +/** + * Closes a feature callout via a click to the dismiss button. + * + * @param {Document} doc The document where the callout appears. + */ +const closeCallout = async doc => { + // close the callout dialog + const dismissBtn = doc.querySelector(`${calloutSelector} .dismiss-button`); + if (!dismissBtn) { + return; + } + doc.querySelector(`${calloutSelector} .dismiss-button`).click(); + await BrowserTestUtils.waitForCondition(() => { + return !document.querySelector(calloutSelector); + }); +}; + +/** + * Get a Feature Callout message by id. + * + * @param {string} Message id + */ +const getCalloutMessageById = id => { + return { + message: FeatureCalloutMessages.getMessages().find(m => m.id === id), + }; +}; + +/** + * Create a sinon sandbox with `sendTriggerMessage` stubbed + * to return a specified test message for featureCalloutCheck. + * + * @param {object} Test message + */ +const createSandboxWithCalloutTriggerStub = testMessage => { + const firefoxViewMatch = sinon.match({ + id: "featureCalloutCheck", + context: { source: "firefoxview" }, + }); + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(firefoxViewMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + return sandbox; +}; + +/** + * A helper to check that correct telemetry was sent by AWSendEventTelemetry. + * This is a wrapper around sinon's spy functionality. + * + * @example + * let spy = new TelemetrySpy(); + * element.click(); + * spy.assertCalledWith({ event: "CLICK" }); + * spy.restore(); + */ +class TelemetrySpy { + /** + * @param {object} [sandbox] A pre-existing sinon sandbox to build the spy in. + * If not provided, a new sandbox will be created. + */ + constructor(sandbox = sinon.createSandbox()) { + this.sandbox = sandbox; + this.spy = this.sandbox + .spy(AboutWelcomeParent.prototype, "onContentMessage") + .withArgs("AWPage:TELEMETRY_EVENT"); + registerCleanupFunction(() => this.restore()); + } + /** + * Assert that AWSendEventTelemetry sent the expected telemetry object. + * @param {Object} expectedData + */ + assertCalledWith(expectedData) { + let match = this.spy.calledWith("AWPage:TELEMETRY_EVENT", expectedData); + if (match) { + ok(true, "Expected telemetry sent"); + } else if (this.spy.called) { + ok( + false, + "Wrong telemetry sent: " + JSON.stringify(this.spy.lastCall.args) + ); + } else { + ok(false, "No telemetry sent"); + } + } + reset() { + this.spy.resetHistory(); + } + restore() { + this.sandbox.restore(); + } +} + +/** + * Helper function to open and close a tab so the recently + * closed tabs list can have data. + * + * @param {string} url + * @return {Promise} Promise that resolves when the session store + * has been updated after closing the tab. + */ +async function open_then_close(url) { + let { updatePromise } = await BrowserTestUtils.withNewTab( + url, + async browser => { + return { + updatePromise: BrowserTestUtils.waitForSessionStoreUpdate({ + linkedBrowser: browser, + }), + }; + } + ); + await updatePromise; + return TestUtils.topicObserved("sessionstore-closed-objects-changed"); +} + +/** + * Clears session history. Used to clear out the recently closed tabs list. + * + */ +function clearHistory() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); +} + +/** + * Cleanup function for tab pickup tests. + * + */ +function cleanup_tab_pickup() { + Services.prefs.clearUserPref("services.sync.engine.tabs"); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF); +} -- cgit v1.2.3