From 8a12eaac21922ec81a0803c274fb64598a11e3a7 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 25 Sep 2026 12:54:02 +0200 Subject: [PATCH] Upstream mozilla-firefox/firefox state of the touched files before eb0738e2cb --- browser/components/firefoxview/syncedtabs.mjs | 456 +++++++ .../browser_firefoxview_search_telemetry.js | 610 +++++++++ .../browser/browser_syncedtabs_firefoxview.js | 1091 +++++++++++++++++ 3 files changed, 2157 insertions(+) create mode 100644 browser/components/firefoxview/syncedtabs.mjs create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js create mode 100644 browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js diff --git a/browser/components/firefoxview/syncedtabs.mjs b/browser/components/firefoxview/syncedtabs.mjs new file mode 100644 index 0000000..405f019 --- /dev/null +++ b/browser/components/firefoxview/syncedtabs.mjs @@ -0,0 +1,456 @@ +/* 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, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SyncedTabsController: "resource:///modules/SyncedTabsController.sys.mjs", +}); + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +import { + html, + ifDefined, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { ViewPage } from "./viewpage.mjs"; +import { + escapeHtmlEntities, + MAX_TABS_FOR_RECENT_BROWSING, + navigateToLink, +} from "./helpers.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/syncedtabs-tab-list.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; + +const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +class SyncedTabsInView extends ViewPage { + controller = new lazy.SyncedTabsController(this, { + contextMenu: true, + pairDeviceCallback: () => + Glean.firefoxviewNext.fxaMobileSync.record({ + has_devices: TabsSetupFlowManager.secondaryDeviceConnected, + }), + signupCallback: () => Glean.firefoxviewNext.fxaContinueSync.record(), + }); + + constructor() { + super(); + this._started = false; + this._id = Math.floor(Math.random() * 10e6); + if (this.recentBrowsing) { + this.maxTabsLength = MAX_TABS_FOR_RECENT_BROWSING; + } else { + // Setting maxTabsLength to -1 for no max + this.maxTabsLength = -1; + } + this.fullyUpdated = false; + this.showAll = false; + this.cumulativeSearches = 0; + this.onSearchQuery = this.onSearchQuery.bind(this); + } + + static properties = { + ...ViewPage.properties, + showAll: { type: Boolean }, + cumulativeSearches: { type: Number }, + }; + + static queries = { + cardEls: { all: "card-container" }, + emptyState: "fxview-empty-state", + searchTextbox: "moz-input-search", + tabLists: { all: "syncedtabs-tab-list" }, + }; + + start() { + if (this._started) { + return; + } + this._started = true; + this.controller.addSyncObservers(); + this.controller.updateStates(); + this.onVisibilityChange(); + + if (this.recentBrowsing) { + this.recentBrowsingElement.addEventListener( + "MozInputSearch:search", + this.onSearchQuery + ); + } + } + + stop() { + if (!this._started) { + return; + } + this._started = false; + TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded"); + this.onVisibilityChange(); + this.controller.removeSyncObservers(); + + if (this.recentBrowsing) { + this.recentBrowsingElement.removeEventListener( + "MozInputSearch:search", + this.onSearchQuery + ); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stop(); + } + + viewVisibleCallback() { + this.start(); + } + + viewHiddenCallback() { + this.stop(); + } + + onVisibilityChange() { + const isOpen = this.open; + const isVisible = this.isVisible; + if (isVisible && isOpen) { + this.update(); + TabsSetupFlowManager.updateViewVisibility(this._id, "visible"); + } else { + TabsSetupFlowManager.updateViewVisibility( + this._id, + isVisible ? "closed" : "hidden" + ); + } + + this.toggleVisibilityInCardContainer(); + } + + generateMessageCard({ + action, + buttonLabel, + descriptionArray, + descriptionLink, + header, + mainImageUrl, + }) { + return html` + + this.controller.handleEvent(e)} + > + + `; + } + + onOpenLink(event) { + navigateToLink(event); + + Glean.firefoxviewNext.syncedTabsTabs.record({ + page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs", + }); + + if (this.controller.searchQuery) { + Glean.firefoxview.cumulativeSearches[ + this.recentBrowsing ? "recentbrowsing" : "syncedtabs" + ].accumulateSingleSample(this.cumulativeSearches); + this.cumulativeSearches = 0; + } + } + + onContextMenu(e) { + this.triggerNode = e.originalTarget; + e.target.querySelector("panel-list").toggle(e.detail.originalEvent); + } + + onCloseTab(e) { + const { url, fxaDeviceId, tertiaryActionClass } = e.originalTarget; + if (tertiaryActionClass === "dismiss-button") { + // Set new pending close tab + this.controller.requestCloseRemoteTab(fxaDeviceId, url); + } else if (tertiaryActionClass === "undo-button") { + // User wants to undo + this.controller.removePendingTabToClose(fxaDeviceId, url); + } + this.requestUpdate(); + } + + panelListTemplate() { + return html` + + + +
+ +
+ `; + } + + noDeviceTabsTemplate(deviceName, deviceType, isSearchResultsEmpty = false) { + const template = html`

+ + ${deviceName} +

+ ${when( + isSearchResultsEmpty, + () => html` +
+ `, + () => html` +
+ ` + )}`; + return this.recentBrowsing + ? template + : html`${template}`; + } + + onSearchQuery(e) { + if (!this.recentBrowsing) { + Glean.firefoxviewNext.searchInitiatedSearch.record({ + page: "syncedtabs", + }); + } + this.controller.searchQuery = e.detail.query; + this.cumulativeSearches = e.detail.query ? this.cumulativeSearches + 1 : 0; + this.showAll = false; + } + + deviceTemplate(deviceName, deviceType, tabItems) { + return html`

+ + ${deviceName} +

+ + ${this.panelListTemplate()} + `; + } + + generateTabList() { + let renderArray = []; + let renderInfo = this.controller.getRenderInfo(); + for (let id in renderInfo) { + let tabItems = renderInfo[id].tabItems; + if (tabItems.length) { + const template = this.recentBrowsing + ? this.deviceTemplate( + renderInfo[id].name, + renderInfo[id].deviceType, + tabItems + ) + : html`${this.deviceTemplate( + renderInfo[id].name, + renderInfo[id].deviceType, + tabItems + )} + `; + renderArray.push(template); + if (this.isShowAllLinkVisible(tabItems)) { + renderArray.push( + html` ` + ); + } + } else { + // Check renderInfo[id].tabs.length to determine whether to display an + // empty tab list message or empty search results message. + // If there are no synced tabs, we always display the empty tab list + // message, even if there is an active search query. + renderArray.push( + this.noDeviceTabsTemplate( + renderInfo[id].name, + renderInfo[id].deviceType, + Boolean(renderInfo[id].tabs.length) + ) + ); + } + } + return renderArray; + } + + isShowAllLinkVisible(tabItems) { + return ( + this.recentBrowsing && + this.controller.searchQuery && + tabItems.length > this.maxTabsLength && + !this.showAll + ); + } + + enableShowAll(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + event.preventDefault(); + this.showAll = true; + Glean.firefoxviewNext.searchShowAllShowallbutton.record({ + section: "syncedtabs", + }); + } + } + + generateCardContent() { + const cardProperties = this.controller.getMessageCard(); + return cardProperties + ? this.generateMessageCard(cardProperties) + : this.generateTabList(); + } + + render() { + this.open = + !TabsSetupFlowManager.isTabSyncSetupComplete || + Services.prefs.getBoolPref(UI_OPEN_STATE, true); + + let renderArray = []; + renderArray.push( + html` ` + ); + renderArray.push( + html` ` + ); + + if (!this.recentBrowsing) { + renderArray.push( + html`
+ +
+
+ +
+ ${when( + this.controller.currentSetupStateIndex === 4, + () => html` + this.controller.handleEvent(e)} + > + ` + )} +
+
` + ); + } + + if (this.recentBrowsing) { + renderArray.push( + html` + > +

+
${this.generateCardContent()}
+
` + ); + } else { + renderArray.push( + html`
${this.generateCardContent()}
` + ); + } + return renderArray; + } + + updated() { + this.fullyUpdated = true; + this.toggleVisibilityInCardContainer(); + } +} +customElements.define("view-syncedtabs", SyncedTabsInView); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js new file mode 100644 index 0000000..d381429 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js @@ -0,0 +1,610 @@ +let gInitialTab; +let gInitialTabURL; + +const NUMBER_OF_TABS = 6; + +const syncedTabsData = [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: Array(NUMBER_OF_TABS) + .fill({ + type: "tab", + title: "Internet for people, not profits - Mozilla", + icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + client: 1, + }) + .map((tab, i) => ({ ...tab, url: URLs[i] })), + }, +]; + +const cleanUp = () => { + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}; + +add_setup(async () => { + gInitialTab = gBrowser.selectedTab; + gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec; + registerCleanupFunction(async () => { + clearHistory(); + }); +}); + +add_task(async function test_search_initiated_telemetry() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + Services.fog.testResetFOG(); + + is(document.location.hash, "", "Searching within recent browsing."); + const recentBrowsing = document.querySelector("view-recentbrowsing"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition( + () => Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(), + "Awaiting search to be reported." + ); + let searchEvents = + Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ page: "recentbrowsing" }, searchEvents[0].extra); + + await navigateToViewAndWait(document, "opentabs"); + Services.fog.testResetFOG(); + is(document.location.hash, "#opentabs", "Searching within open tabs."); + const openTabs = document.querySelector("named-deck > view-opentabs"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition( + () => Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(), + "Awaiting search to be reported." + ); + searchEvents = Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ page: "opentabs" }, searchEvents[0].extra); + + await navigateToViewAndWait(document, "recentlyclosed"); + Services.fog.testResetFOG(); + is( + document.location.hash, + "#recentlyclosed", + "Searching within recently closed." + ); + const recentlyClosed = document.querySelector( + "named-deck > view-recentlyclosed" + ); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentlyClosed.searchTextbox, + {}, + content + ); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition( + () => Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(), + "Awaiting search to be reported." + ); + searchEvents = Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ page: "recentlyclosed" }, searchEvents[0].extra); + + await navigateToViewAndWait(document, "syncedtabs"); + Services.fog.testResetFOG(); + is(document.location.hash, "#syncedtabs", "Searching within synced tabs."); + const syncedTabs = document.querySelector("named-deck > view-syncedtabs"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition( + () => Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(), + "Awaiting search to be reported." + ); + searchEvents = Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ page: "syncedtabs" }, searchEvents[0].extra); + + await navigateToViewAndWait(document, "history"); + Services.fog.testResetFOG(); + is(document.location.hash, "#history", "Searching within history."); + const history = document.querySelector("named-deck > view-history"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(history.searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition( + () => Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(), + "Awaiting search to be reported." + ); + searchEvents = Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ page: "history" }, searchEvents[0].extra); + }); +}); + +add_task(async function test_show_all_recentlyclosed_telemetry() { + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await open_then_close(URLs[1]); + } + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + const recentBrowsing = document.querySelector("view-recentbrowsing"); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("example.com", content); + const recentlyclosedSlot = recentBrowsing.querySelector( + "[slot='recentlyclosed']" + ); + await TestUtils.waitForCondition( + () => + recentlyclosedSlot.tabList.rowEls.length === 5 && + recentlyclosedSlot.shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ), + "Expected search results are not shown yet." + ); + Services.fog.testResetFOG(); + + info("Click the Show All link."); + const showAllButton = recentlyclosedSlot.shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ); + await TestUtils.waitForCondition( + () => !showAllButton.hidden, + "Waiting for the show all button to be visible" + ); + ok(!showAllButton.hidden, "Show all button is visible"); + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content); + if (recentlyclosedSlot.tabList.rowEls.length === NUMBER_OF_TABS) { + return true; + } + return false; + }, "All search results are not shown."); + + const searchEvents = + Glean.firefoxviewNext.searchShowAllShowallbutton.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ section: "recentlyclosed" }, searchEvents[0].extra); + }); +}); + +add_task(async function test_show_all_opentabs_telemetry() { + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]); + } + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + const recentBrowsing = document.querySelector("view-recentbrowsing"); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString(URLs[1], content); + const opentabsSlot = recentBrowsing.querySelector("[slot='opentabs']"); + await TestUtils.waitForCondition( + () => opentabsSlot.viewCards[0].tabList.rowEls.length === 5, + "Expected search results are not shown yet." + ); + Services.fog.testResetFOG(); + + info("Click the Show All link."); + const showAllButton = opentabsSlot.viewCards[0].shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ); + await TestUtils.waitForCondition( + () => !showAllButton.hidden, + "Waiting for the show all button to be visible" + ); + ok(!showAllButton.hidden, "Show all button is visible"); + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content); + if (opentabsSlot.viewCards[0].tabList.rowEls.length === NUMBER_OF_TABS) { + return true; + } + return false; + }, "All search results are not shown."); + + const searchEvents = + Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ page: "recentbrowsing" }, searchEvents[0].extra); + const showAllEvents = + Glean.firefoxviewNext.searchShowAllShowallbutton.testGetValue(); + Assert.equal(1, showAllEvents.length, "Expected one search event."); + Assert.deepEqual({ section: "opentabs" }, showAllEvents[0].extra); + }); + + await SimpleTest.promiseFocus(window); + await promiseAllButPrimaryWindowClosed(); + await BrowserTestUtils.switchTab(gBrowser, gInitialTab); + await closeFirefoxViewTab(window); + + cleanUp(); +}); + +add_task(async function test_show_all_syncedtabs_telemetry() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + const recentBrowsing = document.querySelector("view-recentbrowsing"); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("mozilla", content); + const syncedtabsSlot = recentBrowsing.querySelector("[slot='syncedtabs']"); + await TestUtils.waitForCondition( + () => + syncedtabsSlot.fullyUpdated && + syncedtabsSlot.tabLists.length === 1 && + Promise.all( + Array.from(syncedtabsSlot.tabLists).map( + tabList => tabList.updateComplete + ) + ), + "Synced Tabs component is done updating." + ); + syncedtabsSlot.tabLists[0].scrollIntoView(); + await TestUtils.waitForCondition( + () => syncedtabsSlot.tabLists[0].rowEls.length === 5, + "Expected search results are not shown yet." + ); + Services.fog.testResetFOG(); + + const showAllButton = await TestUtils.waitForCondition( + () => + syncedtabsSlot.shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ), + "Waiting for the show all button to be available in the synced tabs slot" + ); + info("Scroll show all button into view."); + showAllButton.scrollIntoView(); + await TestUtils.waitForCondition( + () => !showAllButton.hidden, + "Waiting for the show all button to be visible" + ); + ok(!showAllButton.hidden, "Show all button is visible"); + info("Click the Show All link."); + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content); + if (syncedtabsSlot.tabLists[0].rowEls.length === NUMBER_OF_TABS) { + return true; + } + return false; + }, "All search results are not shown."); + + const searchEvents = + Glean.firefoxviewNext.searchInitiatedSearch.testGetValue(); + Assert.equal(1, searchEvents.length, "Expected one search event."); + Assert.deepEqual({ page: "recentbrowsing" }, searchEvents[0].extra); + const showAllEvents = + Glean.firefoxviewNext.searchShowAllShowallbutton.testGetValue(); + Assert.equal(1, showAllEvents.length, "Expected one search event."); + Assert.deepEqual({ section: "syncedtabs" }, showAllEvents[0].extra); + }); + + await tearDown(sandbox); +}); + +add_task(async function test_sort_history_search_telemetry() { + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await open_then_close(URLs[i]); + } + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "history"); + const historyComponent = document.querySelector("view-history"); + + const searchTextbox = await TestUtils.waitForCondition( + () => historyComponent.searchTextbox, + "The search textbox is displayed." + ); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition(() => { + const { rowEls } = historyComponent.lists[0]; + return rowEls.length === 1; + }, "There is one matching search result."); + Services.fog.testResetFOG(); + // Select sort by site option + EventUtils.synthesizeMouseAtCenter( + historyComponent.sortInputs[1], + {}, + content + ); + await TestUtils.waitForCondition( + () => historyComponent.fullyUpdated, + "Waiting for the history component to be fully updated" + ); + let historyEvents = Glean.firefoxviewNext.sortHistoryTabs.testGetValue(); + Assert.equal(1, historyEvents.length, "Expected one history event."); + Assert.deepEqual( + { sort_type: "site", search_start: "true" }, + historyEvents[0].extra + ); + + Services.fog.testResetFOG(); + + // Select sort by date option + EventUtils.synthesizeMouseAtCenter( + historyComponent.sortInputs[0], + {}, + content + ); + await TestUtils.waitForCondition( + () => historyComponent.fullyUpdated, + "Waiting for the history component to be fully updated" + ); + historyEvents = Glean.firefoxviewNext.sortHistoryTabs.testGetValue(); + Assert.equal(1, historyEvents.length, "Expected one history event."); + Assert.deepEqual( + { sort_type: "date", search_start: "true" }, + historyEvents[0].extra + ); + }); +}); + +add_task(async function test_cumulative_searches_recent_browsing_telemetry() { + await PlacesUtils.history.clear(); + await open_then_close(URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + is(document.location.hash, "", "Searching within recent browsing."); + const recentBrowsing = document.querySelector("view-recentbrowsing"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString(URLs[0], content); + const recentlyclosedSlot = recentBrowsing.querySelector( + "[slot='recentlyclosed']" + ); + await TestUtils.waitForCondition( + () => + recentlyclosedSlot?.tabList?.rowEls?.length && + recentlyclosedSlot?.searchQuery, + "Expected search results are not shown yet." + ); + + EventUtils.synthesizeMouseAtCenter( + recentlyclosedSlot.tabList.rowEls[0].mainEl, + {}, + content + ); + Assert.equal( + 1, + Glean.firefoxview.cumulativeSearches.testGetValue().recentbrowsing.sum + ); + }); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_recently_closed_telemetry() { + await PlacesUtils.history.clear(); + await open_then_close(URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToViewAndWait(document, "recentlyclosed"); + is( + document.location.hash, + "#recentlyclosed", + "Searching within recently closed." + ); + const recentlyClosed = document.querySelector( + "named-deck > view-recentlyclosed" + ); + const searchTextbox = await TestUtils.waitForCondition(() => { + return recentlyClosed.searchTextbox; + }, "Waiting for the recently closed search textbox to be available"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + // eslint-disable-next-line no-unused-vars + const [recentlyclosedSlot, tabList] = + await waitForRecentlyClosedTabsList(document); + await TestUtils.waitForCondition( + () => recentlyclosedSlot?.searchQuery, + "Waiting for the recently closed slot to have a search query" + ); + + await click_recently_closed_tab_item(tabList[0]); + + Assert.equal( + 1, + Glean.firefoxview.cumulativeSearches.testGetValue().recentlyclosed.sum + ); + }); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_open_tabs_telemetry() { + await PlacesUtils.history.clear(); + await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToViewAndWait(document, "opentabs"); + is(document.location.hash, "#opentabs", "Searching within open tabs."); + const openTabs = document.querySelector("named-deck > view-opentabs"); + + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + let cards; + await TestUtils.waitForCondition(() => { + cards = getOpenTabsCards(openTabs); + return cards.length == 1; + }, "There is one open tab card"); + await TestUtils.waitForCondition( + () => cards[0].tabList.rowEls.length === 1 && openTabs?.searchQuery, + "Expected search results are not shown yet." + ); + + info("Click a search result tab"); + EventUtils.synthesizeMouseAtCenter( + cards[0].tabList.rowEls[0].mainEl, + {}, + content + ); + }); + + Assert.equal( + 1, + Glean.firefoxview.cumulativeSearches.testGetValue().opentabs.sum + ); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_history_telemetry() { + await PlacesUtils.history.clear(); + await open_then_close(URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToViewAndWait(document, "history"); + is(document.location.hash, "#history", "Searching within history."); + const history = document.querySelector("named-deck > view-history"); + const searchTextbox = await TestUtils.waitForCondition(() => { + return history.searchTextbox; + }); + + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + await TestUtils.waitForCondition( + () => + history.fullyUpdated && + history?.lists[0].rowEls?.length === 1 && + history?.controller?.searchQuery, + "Expected search results are not shown yet." + ); + + info("Click a search result tab"); + EventUtils.synthesizeMouseAtCenter( + history.lists[0].rowEls[0].mainEl, + {}, + content + ); + + Assert.equal( + 1, + Glean.firefoxview.cumulativeSearches.testGetValue().history.sum + ); + }); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_syncedtabs_telemetry() { + await PlacesUtils.history.clear(); + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await navigateToViewAndWait(document, "syncedtabs"); + is(document.location.hash, "#syncedtabs", "Searching within synced tabs."); + let syncedTabs = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + await TestUtils.waitForCondition( + () => + syncedTabs.fullyUpdated && + syncedTabs.tabLists.length === 1 && + Promise.all( + Array.from(syncedTabs.tabLists).map(tabList => tabList.updateComplete) + ), + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabs.tabLists[0].rowEls.length === 1 && + syncedTabs.controller.searchQuery, + "Expected search results are not shown yet." + ); + + info("Click a search result tab"); + EventUtils.synthesizeMouseAtCenter( + syncedTabs.tabLists[0].rowEls[0].mainEl, + {}, + content + ); + + Assert.equal( + 1, + Glean.firefoxview.cumulativeSearches.testGetValue().syncedtabs.sum + ); + }); + + cleanUp(); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js new file mode 100644 index 0000000..cd15537 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js @@ -0,0 +1,1091 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +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 () { + await tearDown(gSandbox); + }); + + // set tab sync false so we don't skip setup states + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", false]], + }); +}); + +async function promiseTabListsUpdated({ tabLists }) { + for (const tabList of tabLists) { + await tabList.updateComplete; + } + await TestUtils.waitForTick(); +} + +add_task(async function test_unconfigured_initial_state() { + const sandbox = setupMocks({ + state: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-signin"), + "Signin message is shown" + ); + + // Test telemetry for signing into Firefox Accounts. + Services.fog.testResetFOG(); + EventUtils.synthesizeMouseAtCenter( + emptyState.querySelector(`moz-button[data-action="sign-in"]`).buttonEl, + {}, + browser.contentWindow + ); + Assert.equal( + 1, + Glean.firefoxviewNext.fxaContinueSync.testGetValue().length, + "Expected one fxa_continue event." + ); + await BrowserTestUtils.removeTab( + browser.documentGlobal.gBrowser.selectedTab + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_loading_state() { + const isNovaEnabled = Services.prefs.getBoolPref( + "browser.nova.enabled", + false + ); + const loadingHeaderL10nId = isNovaEnabled + ? "firefoxview-syncedtabs-loading-header-2" + : "firefoxview-syncedtabs-loading-header"; + const loadingImageUrl = isNovaEnabled + ? "chrome://browser/skin/sidebar/kit-tabs-devices.svg" + : "chrome://browser/content/firefoxview/synced-tabs-empty.svg"; + + const { SyncedTabsErrorHandler } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs" + ); + const sandbox = sinon.createSandbox(); + sandbox.stub(TabsSetupFlowManager, "uiStateIndex").value(0); + sandbox.stub(SyncedTabsErrorHandler, "getErrorType").returns(null); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced tabs component is fully updated." + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + is( + emptyState.getAttribute("headerlabel"), + loadingHeaderL10nId, + "Loading message is shown" + ); + + const image = emptyState.shadowRoot.querySelector("img.image"); + ok( + !image.hidden, + "The main image is not hidden while the loading state is shown" + ); + is( + image.getAttribute("src"), + loadingImageUrl, + "The loading state shows its configured main image" + ); + }); + sandbox.restore(); +}); + +add_task(async function test_signed_in() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-adddevice"), + "Add device message is shown" + ); + + const mockConnectAdditionDevicesPath = "https://example.com/"; + let expectedUrl = + "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync"; + let connectAdditionalDevicesLink = + emptyState?.shadowRoot.querySelector("a"); + connectAdditionalDevicesLink.scrollIntoView(); + await BrowserTestUtils.waitForMutationCondition( + emptyState.shadowRoot, + { subTree: true, childList: true }, + () => BrowserTestUtils.isVisible(connectAdditionalDevicesLink) + ); + ok( + BrowserTestUtils.isVisible(connectAdditionalDevicesLink), + "Support url is visible" + ); + is( + connectAdditionalDevicesLink.href, + expectedUrl, + "Support link href is correct" + ); + connectAdditionalDevicesLink.href = mockConnectAdditionDevicesPath; + info("Mock click on support link"); + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + mockConnectAdditionDevicesPath + ); + connectAdditionalDevicesLink.click(); + let tab = await tabPromise; + is( + tab.linkedBrowser.currentURI.spec, + mockConnectAdditionDevicesPath, + "Navigated to mock support link" + ); + + await openFirefoxViewTab(window); + // Test telemetry for adding a device. + Services.fog.testResetFOG(); + // TODO: Exercise the QR-code based flow on Nova, once implemented. + // FIXME: Bug 2058059 - [Nova] On the tabs from other devices panel there is no QR code displayed when signed in to sync with no devices connected + if (!Services.prefs.getBoolPref("browser.nova.enabled", false)) { + EventUtils.synthesizeMouseAtCenter( + emptyState.querySelector(`moz-button[data-action="add-device"]`) + .buttonEl, + {}, + browser.contentWindow + ); + Assert.equal( + 1, + Glean.firefoxviewNext.fxaMobileSync.testGetValue().length, + "Expected one fxa_continue event." + ); + } + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + await BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + }); + await tearDown(sandbox); +}); + +add_task(async function test_no_synced_tabs() { + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-synctabs"), + "Enable synced tabs message is shown" + ); + }); + await tearDown(sandbox); + Services.prefs.setBoolPref("services.sync.engine.tabs", true); +}); + +add_task(async function test_no_error_for_two_desktop() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + is(emptyState, null, "No empty state should be shown"); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 1, "Should be 1 empty device"); + }); + await tearDown(sandbox); +}); + +add_task(async function test_empty_state() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Desktop", + type: "desktop", + tabs: [], + }, + { + id: 3, + name: "Other Mobile", + type: "phone", + tabs: [], + }, + ], + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 2, "Should be 2 empty devices"); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("Other Desktop"), + "Text is correct (Desktop)" + ); + ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop"); + ok( + headers[1].textContent.includes("Other Mobile"), + "Text is correct (Mobile)" + ); + ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone"); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tabs() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData1); + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + // Notify observers while in recent browsing. Once synced tabs is selected, + // it should have the updated data. + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("My desktop"), + "Text is correct (My desktop)" + ); + ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop"); + ok( + headers[1].textContent.includes("My iphone"), + "Text is correct (My iphone)" + ); + ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone"); + + let tabLists = syncedTabsComponent.tabLists; + await TestUtils.waitForCondition(() => { + return tabLists[0].rowEls.length; + }, "Waiting for the first synced tab list to have row elements"); + let tabRow1 = tabLists[0].rowEls; + ok( + tabRow1[0].shadowRoot.textContent.includes, + "Internet for people, not profits - Mozilla" + ); + ok(tabRow1[1].shadowRoot.textContent.includes, "Sandboxes - Sinon.JS"); + is(tabRow1.length, 2, "Correct number of rows are displayed."); + let tabRow2 = tabLists[1].rowEls; + is(tabRow2.length, 2, "Correct number of rows are dispayed."); + ok(tabRow1[0].shadowRoot.textContent.includes, "The Guardian"); + ok(tabRow1[1].shadowRoot.textContent.includes, "The Times"); + + // Test telemetry for opening a tab. + Services.fog.testResetFOG(); + EventUtils.synthesizeMouseAtCenter(tabRow1[0], {}, browser.contentWindow); + const syncEvents = Glean.firefoxviewNext.syncedTabsTabs.testGetValue(); + Assert.equal(1, syncEvents.length, "Expected one synced_tabs event."); + Assert.deepEqual({ page: "syncedtabs" }, syncEvents[0].extra); + }); + await tearDown(sandbox); +}); + +add_task(async function test_empty_desktop_same_name() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "A Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "A Device", + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 1, "Should be 1 empty devices"); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("A Device"), + "Text is correct (Desktop)" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_empty_desktop_same_name_three() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "A Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "A Device", + type: "desktop", + tabs: [], + }, + { + id: 3, + name: "A Device", + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 2, "Should be 2 empty devices"); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("A Device"), + "Text is correct (Desktop)" + ); + ok( + headers[1].textContent.includes("A Device"), + "Text is correct (Desktop)" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function search_synced_tabs() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Waiting for the synced tabs component to be fully updated" + ); + + is(syncedTabsComponent.cardEls.length, 2, "There are two device cards."); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length && + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") + .rowEls.length, + "The tab list has loaded for the first two cards." + ); + let deviceOneTabs = syncedTabsComponent.cardEls[0].querySelector( + "syncedtabs-tab-list" + ).rowEls; + let deviceTwoTabs = syncedTabsComponent.cardEls[1].querySelector( + "syncedtabs-tab-list" + ).rowEls; + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + syncedTabsComponent.searchTextbox, + {}, + content + ); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length, + "The tab list has loaded for the first card." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length === 1, + "There is one matching search result for the first device." + ); + await TestUtils.waitForCondition( + () => + !syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list"), + "There are no matching search results for the second device." + ); + + info("Clear the search query."); + let clearButton = SpecialPowers.getInputButton( + syncedTabsComponent.searchTextbox.inputEl + ); + info(`CLEAR BUTTON: ${clearButton}`); + EventUtils.synthesizeMouseAtCenter(clearButton, {}, content); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length && + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") + .rowEls.length, + "The tab list has loaded for the first two cards." + ); + deviceOneTabs = syncedTabsComponent.cardEls[0].querySelector( + "syncedtabs-tab-list" + ).rowEls; + deviceTwoTabs = syncedTabsComponent.cardEls[1].querySelector( + "syncedtabs-tab-list" + ).rowEls; + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length === deviceOneTabs.length, + "The original device's list is restored." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") + .rowEls.length === deviceTwoTabs.length, + "The new devices's list is restored." + ); + syncedTabsComponent.searchTextbox.blur(); + + info("Input a search query with keyboard."); + EventUtils.synthesizeKey("f", { accelKey: true }, content); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length, + "The tab list has loaded for the first card." + ); + await TestUtils.waitForCondition(() => { + return ( + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length === 1 + ); + }, "There is one matching search result for the first device."); + await TestUtils.waitForCondition( + () => + !syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list"), + "There are no matching search results for the second device." + ); + + info("Clear the search query."); + clearButton = SpecialPowers.getInputButton( + syncedTabsComponent.searchTextbox.inputEl + ); + EventUtils.synthesizeMouseAtCenter(clearButton, {}, content); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length && + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") && + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") + .rowEls.length, + "The tab list has loaded for the first two cards." + ); + deviceOneTabs = syncedTabsComponent.cardEls[0].querySelector( + "syncedtabs-tab-list" + ).rowEls; + deviceTwoTabs = syncedTabsComponent.cardEls[1].querySelector( + "syncedtabs-tab-list" + ).rowEls; + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("syncedtabs-tab-list") + .rowEls.length === deviceOneTabs.length, + "The original device's list is restored." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[1].querySelector("syncedtabs-tab-list") + .rowEls.length === deviceTwoTabs.length, + "The new devices's list is restored." + ); + }); + await SpecialPowers.popPrefEnv(); + await tearDown(sandbox); +}); + +add_task(async function search_synced_tabs_recent_browsing() { + const NUMBER_OF_TABS = 6; + TabsSetupFlowManager.resetInternalState(); + const sandbox = setupRecentDeviceListMocks(); + const tabClients = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + tabs: Array(NUMBER_OF_TABS) + .fill() + .map((_, i) => { + return { + type: "tab", + title: "Internet for people, not profits - Mozilla", + url: `https://www.mozilla.org/${i}`, + icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + client: 1, + }; + }), + }, + { + id: 2, + type: "client", + name: "My iphone", + clientType: "phone", + tabs: [ + { + type: "tab", + title: "Mount Everest - Wikipedia", + url: "https://en.wikipedia.org/wiki/Mount_Everest", + icon: "https://www.wikipedia.org/static/favicon/wikipedia.ico", + client: 2, + }, + ], + }, + ]; + sandbox + .stub(SyncedTabs, "getRecentTabs") + .resolves(getMockTabData(tabClients)); + sandbox.stub(SyncedTabs, "getTabClients").resolves(tabClients); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "recentbrowsing"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + const recentBrowsing = document.querySelector("view-recentbrowsing"); + const slot = recentBrowsing.querySelector("[slot='syncedtabs']"); + + // Test that all tab lists repopulate when clearing out searched terms (Bug 1869895 & Bug 1873212) + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => slot.fullyUpdated && slot.tabLists.length === 1, + "Synced Tabs component is done updating." + ); + await promiseTabListsUpdated(slot); + info("Scroll first card into view."); + slot.tabLists[0].scrollIntoView(); + await TestUtils.waitForCondition( + () => slot.tabLists[0].rowEls.length === 5, + "The first card is populated." + ); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.synthesizeKey("KEY_Backspace", { repeat: 5 }); + await TestUtils.waitForCondition( + () => slot.fullyUpdated && slot.tabLists.length === 2, + "Synced Tabs component is done updating." + ); + await promiseTabListsUpdated(slot); + info("Scroll second card into view."); + slot.tabLists[1].scrollIntoView(); + await TestUtils.waitForCondition( + () => + slot.tabLists[0].rowEls.length === 5 && + slot.tabLists[1].rowEls.length === 1, + "Both cards are populated." + ); + info("Clear the search query."); + EventUtils.synthesizeKey("KEY_Backspace", { repeat: 2 }); + + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => slot.fullyUpdated && slot.tabLists.length === 2, + "Synced Tabs component is done updating." + ); + await promiseTabListsUpdated(slot); + await TestUtils.waitForCondition( + () => slot.tabLists[0].rowEls.length === 5, + "Not all search results are shown yet." + ); + + info("Click the Show All link."); + const showAllLink = await TestUtils.waitForCondition( + () => + slot.shadowRoot.querySelector("[data-l10n-id='firefoxview-show-all']"), + "The show all link to be available in the slot's shadow root" + ); + is(showAllLink.role, "link", "The show all control is a link."); + EventUtils.synthesizeMouseAtCenter(showAllLink, {}, content); + await TestUtils.waitForCondition( + () => slot.tabLists[0].rowEls.length === NUMBER_OF_TABS, + "All search results are shown." + ); + ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden."); + }); + await SpecialPowers.popPrefEnv(); + await tearDown(sandbox); +}); + +add_task(async function view_all_synced_tabs_recent_browsing() { + const NUMBER_OF_TABS = 1; + TabsSetupFlowManager.resetInternalState(); + const sandbox = setupRecentDeviceListMocks(); + const tabClients = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + tabs: Array(NUMBER_OF_TABS) + .fill() + .map((_, i) => { + return { + type: "tab", + title: "Internet for people, not profits - Mozilla", + url: `https://www.mozilla.org/${i}`, + icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + client: 1, + }; + }), + }, + { + id: 2, + type: "client", + name: "My iphone", + clientType: "phone", + tabs: [ + { + type: "tab", + title: "Mount Everest - Wikipedia", + url: "https://en.wikipedia.org/wiki/Mount_Everest", + icon: "https://www.wikipedia.org/static/favicon/wikipedia.ico", + client: 2, + }, + ], + }, + ]; + sandbox + .stub(SyncedTabs, "getRecentTabs") + .resolves(getMockTabData(tabClients)); + sandbox.stub(SyncedTabs, "getTabClients").resolves(tabClients); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToViewAndWait(document, "recentbrowsing"); + // Notify observers while in recent browsing. Once synced tabs is selected, + // it should have the updated data. + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + const syncedTabsComponent = document.querySelector( + "view-syncedtabs[slot=syncedtabs]" + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "view-syncedtabs[slot=syncedtabs] is fullyUpdated" + ); + + const viewAllLink = + syncedTabsComponent.cardEls[0].shadowRoot.querySelector( + "a.view-all-link" + ); + ok( + viewAllLink.href.startsWith(getFirefoxViewURL()), + `The view all link links to about:firefoxview: ${viewAllLink.href}` + ); + + const openTabsCount = gBrowser.tabs.length; + const pagesDeck = document.querySelector("named-deck"); + + viewAllLink.click(); + + await BrowserTestUtils.waitForMutationCondition( + pagesDeck, + { attributes: true }, + () => pagesDeck.selectedViewName !== "recentbrowsing" + ); + is(gBrowser.tabs.length, openTabsCount, "No new tabs were opened"); + Assert.ok( + FirefoxViewHandler.tab.selected, + "Firefox View tab is still selected" + ); + Assert.equal( + pagesDeck.selectedViewName, + "syncedtabs", + "syncedtabs is the selected view after clicking the view-all button" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_connected() { + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "mobile", + tabs: [], + }, + ], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + // 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 navigateToViewAndWait(document, "syncedtabs"); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "mobile" + ), + "A connected device is type:mobile" + ); + }); + await tearDown(sandbox); + Services.prefs.setBoolPref("services.sync.engine.tabs", true); +}); + +add_task(async function test_tablet_connected() { + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "tablet", + tabs: [], + }, + ], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + // 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 navigateToViewAndWait(document, "syncedtabs"); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "tablet" + ), + "A connected device is type:tablet" + ); + }); + await tearDown(sandbox); + Services.prefs.setBoolPref("services.sync.engine.tabs", true); +}); + +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", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "mobile", + tabs: [], + }, + ], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + + // test initial state, with the pref not enabled + await navigateToViewAndWait(document, "syncedtabs"); + // test with the pref toggled on + Services.prefs.setBoolPref("services.sync.engine.tabs", true); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced tabs component is fully updated." + ); + ok(!syncedTabsComponent.emptyState, "No empty state is being displayed."); + + // reset and test clicking the action button + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced tabs component is fully updated." + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.emptyState, + "The empty state is rendered." + ); + + const actionButton = syncedTabsComponent.emptyState?.querySelector( + "moz-button[data-action=sync-tabs-disabled]" + ); + EventUtils.synthesizeMouseAtCenter( + actionButton.buttonEl, + {}, + browser.contentWindow + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced tabs component is fully updated." + ); + await TestUtils.waitForCondition( + () => !syncedTabsComponent.emptyState, + "The empty state is rendered." + ); + + ok(true, "The empty state is no longer displayed 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); +});