diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/syncedtabs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
22 files changed, 3168 insertions, 0 deletions
diff --git a/browser/components/syncedtabs/EventEmitter.sys.mjs b/browser/components/syncedtabs/EventEmitter.sys.mjs new file mode 100644 index 0000000000..ed026dc173 --- /dev/null +++ b/browser/components/syncedtabs/EventEmitter.sys.mjs @@ -0,0 +1,36 @@ +/* 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/. */ + +// Simple event emitter abstraction for storage objects to use. +export function EventEmitter() { + this._events = new Map(); +} + +EventEmitter.prototype = { + on(event, listener) { + if (this._events.has(event)) { + this._events.get(event).add(listener); + } else { + this._events.set(event, new Set([listener])); + } + }, + off(event, listener) { + if (!this._events.has(event)) { + return; + } + this._events.get(event).delete(listener); + }, + emit(event, ...args) { + if (!this._events.has(event)) { + return; + } + for (let listener of this._events.get(event).values()) { + try { + listener.apply(this, args); + } catch (e) { + console.error(e); + } + } + }, +}; diff --git a/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs new file mode 100644 index 0000000000..47571f789d --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SyncedTabsDeckStore } from "resource:///modules/syncedtabs/SyncedTabsDeckStore.sys.mjs"; +import { SyncedTabsDeckView } from "resource:///modules/syncedtabs/SyncedTabsDeckView.sys.mjs"; +import { SyncedTabsListStore } from "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs"; +import { TabListComponent } from "resource:///modules/syncedtabs/TabListComponent.sys.mjs"; +import { TabListView } from "resource:///modules/syncedtabs/TabListView.sys.mjs"; +import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs"; +import { UIState } from "resource://services-sync/UIState.sys.mjs"; + +/* SyncedTabsDeckComponent + * This component instantiates views and storage objects as well as defines + * behaviors that will be passed down to the views. This helps keep the views + * isolated and easier to test. + */ + +export function SyncedTabsDeckComponent({ + window, + SyncedTabs, + deckStore, + listStore, + listComponent, + DeckView, + getChromeWindowMock, +}) { + this._window = window; + this._SyncedTabs = SyncedTabs; + this._DeckView = DeckView || SyncedTabsDeckView; + // used to stub during tests + this._getChromeWindow = getChromeWindowMock || getChromeWindow; + + this._deckStore = deckStore || new SyncedTabsDeckStore(); + this._syncedTabsListStore = listStore || new SyncedTabsListStore(SyncedTabs); + this.tabListComponent = + listComponent || + new TabListComponent({ + window: this._window, + store: this._syncedTabsListStore, + View: TabListView, + SyncedTabs, + clipboardHelper: Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ), + getChromeWindow: this._getChromeWindow, + }); +} + +SyncedTabsDeckComponent.prototype = { + PANELS: { + TABS_CONTAINER: "tabs-container", + TABS_FETCHING: "tabs-fetching", + LOGIN_FAILED: "reauth", + NOT_AUTHED_INFO: "notAuthedInfo", + SYNC_DISABLED: "syncDisabled", + SINGLE_DEVICE_INFO: "singleDeviceInfo", + TABS_DISABLED: "tabs-disabled", + UNVERIFIED: "unverified", + }, + + get container() { + return this._deckView ? this._deckView.container : null; + }, + + init() { + Services.obs.addObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED); + Services.obs.addObserver(this, UIState.ON_UPDATE); + + // Add app locale change support for HTML sidebar + Services.obs.addObserver(this, "intl:app-locales-changed"); + this.updateDir(); + + // Go ahead and trigger sync + this._SyncedTabs.syncTabs().catch(console.error); + + this._deckView = new this._DeckView(this._window, this.tabListComponent, { + onConnectDeviceClick: event => this.openConnectDevice(event), + onSyncPrefClick: event => this.openSyncPrefs(event), + }); + + this._deckStore.on("change", state => this._deckView.render(state)); + // Trigger the initial rendering of the deck view + // Object.values only in nightly + this._deckStore.setPanels( + Object.keys(this.PANELS).map(k => this.PANELS[k]) + ); + // Set the initial panel to display + this.updatePanel(); + }, + + uninit() { + Services.obs.removeObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED); + Services.obs.removeObserver(this, UIState.ON_UPDATE); + Services.obs.removeObserver(this, "intl:app-locales-changed"); + this._deckView.destroy(); + }, + + observe(subject, topic, data) { + switch (topic) { + case this._SyncedTabs.TOPIC_TABS_CHANGED: + this._syncedTabsListStore.getData(); + this.updatePanel(); + break; + case UIState.ON_UPDATE: + this.updatePanel(); + break; + case "intl:app-locales-changed": + this.updateDir(); + break; + default: + break; + } + }, + + async getPanelStatus() { + try { + const state = UIState.get(); + const { status } = state; + if (status == UIState.STATUS_NOT_CONFIGURED) { + return this.PANELS.NOT_AUTHED_INFO; + } else if (status == UIState.STATUS_LOGIN_FAILED) { + return this.PANELS.LOGIN_FAILED; + } else if (status == UIState.STATUS_NOT_VERIFIED) { + return this.PANELS.UNVERIFIED; + } else if (!state.syncEnabled) { + return this.PANELS.SYNC_DISABLED; + } else if (!this._SyncedTabs.isConfiguredToSyncTabs) { + return this.PANELS.TABS_DISABLED; + } else if (!this._SyncedTabs.hasSyncedThisSession) { + return this.PANELS.TABS_FETCHING; + } + const clients = await this._SyncedTabs.getTabClients(); + if (clients.length) { + return this.PANELS.TABS_CONTAINER; + } + return this.PANELS.SINGLE_DEVICE_INFO; + } catch (err) { + console.error(err); + return this.PANELS.NOT_AUTHED_INFO; + } + }, + + updateDir() { + // If the HTML document doesn't exist, we can't update the window + if (!this._window.document) { + return; + } + + if (Services.locale.isAppLocaleRTL) { + this._window.document.body.dir = "rtl"; + } else { + this._window.document.body.dir = "ltr"; + } + }, + + updatePanel() { + // return promise for tests + return this.getPanelStatus() + .then(panelId => this._deckStore.selectPanel(panelId)) + .catch(console.error); + }, + + openSyncPrefs() { + this._getChromeWindow(this._window).gSync.openPrefs("tabs-sidebar"); + }, + + openConnectDevice() { + this._getChromeWindow(this._window).gSync.openConnectAnotherDevice( + "tabs-sidebar" + ); + }, +}; diff --git a/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs new file mode 100644 index 0000000000..bea52bc55a --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs"; + +/** + * SyncedTabsDeckStore + * + * This store keeps track of the deck view state, including the panels and which + * one is selected. The view listens for change events on the store, which are + * triggered whenever the state changes. If it's a small change, the state + * will have `isUpdatable` set to true so the view can skip rerendering the whole + * DOM. + */ +export function SyncedTabsDeckStore() { + EventEmitter.call(this); + this._panels = []; +} + +Object.assign(SyncedTabsDeckStore.prototype, EventEmitter.prototype, { + _change(isUpdatable = false) { + let panels = this._panels.map(panel => { + return { id: panel, selected: panel === this._selectedPanel }; + }); + this.emit("change", { panels, isUpdatable }); + }, + + /** + * Sets the selected panelId and triggers a change event. + * + * @param {string} panelId - ID of the panel to select. + */ + selectPanel(panelId) { + if (!this._panels.includes(panelId) || this._selectedPanel === panelId) { + return; + } + this._selectedPanel = panelId; + this._change(true); + }, + + /** + * Update the set of panels in the deck and trigger a change event. + * + * @param {Array} panels - an array of IDs for each panel in the deck. + */ + setPanels(panels) { + if (panels === this._panels) { + return; + } + this._panels = panels || []; + this._change(); + }, +}); diff --git a/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs new file mode 100644 index 0000000000..aacae71fa5 --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +/** + * SyncedTabsDeckView + * + * Instances of SyncedTabsDeckView render DOM nodes from a given state. + * No state is kept internaly and the DOM will completely + * rerender unless the state flags `isUpdatable`, which helps + * make small changes without the overhead of a full rerender. + */ +export const SyncedTabsDeckView = function (window, tabListComponent, props) { + this.props = props; + + this._window = window; + this._doc = window.document; + + this._tabListComponent = tabListComponent; + this._deckTemplate = this._doc.getElementById("deck-template"); + this.container = this._doc.createElement("div"); +}; + +SyncedTabsDeckView.prototype = { + render(state) { + if (state.isUpdatable) { + this.update(state); + } else { + this.create(state); + } + }, + + create(state) { + let deck = this._doc.importNode( + this._deckTemplate.content, + true + ).firstElementChild; + this._clearChilden(); + + let tabListWrapper = this._doc.createElement("div"); + tabListWrapper.className = "tabs-container sync-state"; + this._tabListComponent.init(); + tabListWrapper.appendChild(this._tabListComponent.container); + deck.appendChild(tabListWrapper); + this.container.appendChild(deck); + + this._attachListeners(); + this.update(state); + }, + + destroy() { + this._tabListComponent.uninit(); + this.container.remove(); + }, + + update(state) { + // Note that we may also want to update elements that are outside of the + // deck, so use the document to find the class names rather than our + // container. + for (let panel of state.panels) { + if (panel.selected) { + Array.prototype.map.call( + this._doc.getElementsByClassName(panel.id), + item => item.classList.add("selected") + ); + } else { + Array.prototype.map.call( + this._doc.getElementsByClassName(panel.id), + item => item.classList.remove("selected") + ); + } + } + }, + + _clearChilden() { + while (this.container.firstChild) { + this.container.firstChild.remove(); + } + }, + + _attachListeners() { + let syncPrefLinks = this.container.querySelectorAll(".sync-prefs"); + for (let link of syncPrefLinks) { + link.addEventListener("click", this.props.onSyncPrefClick); + } + this.container + .querySelector(".connect-device") + .addEventListener("click", this.props.onConnectDeviceClick); + }, +}; diff --git a/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs b/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs new file mode 100644 index 0000000000..67adcfdace --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs @@ -0,0 +1,253 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs"; + +/** + * SyncedTabsListStore + * + * Instances of this store encapsulate all of the state associated with a synced tabs list view. + * The state includes the clients, their tabs, the row that is currently selected, + * and the filtered query. + */ +export function SyncedTabsListStore(SyncedTabs) { + EventEmitter.call(this); + this._SyncedTabs = SyncedTabs; + this.data = []; + this._closedClients = {}; + this._selectedRow = [-1, -1]; + this.filter = ""; + this.inputFocused = false; +} + +Object.assign(SyncedTabsListStore.prototype, EventEmitter.prototype, { + // This internal method triggers the "change" event that views + // listen for. It denormalizes the state so that it's easier for + // the view to deal with. updateType hints to the view what + // actually needs to be rerendered or just updated, and can be + // empty (to (re)render everything), "searchbox" (to rerender just the tab list), + // or "all" (to skip rendering and just update all attributes of existing nodes). + _change(updateType) { + let selectedParent = this._selectedRow[0]; + let selectedChild = this._selectedRow[1]; + let rowSelected = false; + // clone the data so that consumers can't mutate internal storage + let data = Cu.cloneInto(this.data, {}); + let tabCount = 0; + + data.forEach((client, index) => { + client.closed = !!this._closedClients[client.id]; + + if (rowSelected || selectedParent < 0) { + return; + } + if (this.filter) { + if (selectedParent < tabCount + client.tabs.length) { + client.tabs[selectedParent - tabCount].selected = true; + client.tabs[selectedParent - tabCount].focused = !this.inputFocused; + rowSelected = true; + } else { + tabCount += client.tabs.length; + } + return; + } + if (selectedParent === index && selectedChild === -1) { + client.selected = true; + client.focused = !this.inputFocused; + rowSelected = true; + } else if (selectedParent === index) { + client.tabs[selectedChild].selected = true; + client.tabs[selectedChild].focused = !this.inputFocused; + rowSelected = true; + } + }); + + // If this were React the view would be smart enough + // to not re-render the whole list unless necessary. But it's + // not, so updateType is a hint to the view of what actually + // needs to be rerendered. + this.emit("change", { + clients: data, + canUpdateAll: updateType === "all", + canUpdateInput: updateType === "searchbox", + filter: this.filter, + inputFocused: this.inputFocused, + }); + }, + + /** + * Moves the row selection from a child to its parent, + * which occurs when the parent of a selected row closes. + */ + _selectParentRow() { + this._selectedRow[1] = -1; + }, + + _toggleBranch(id, closed) { + this._closedClients[id] = closed; + if (this._closedClients[id]) { + this._selectParentRow(); + } + this._change("all"); + }, + + _isOpen(client) { + return !this._closedClients[client.id]; + }, + + moveSelectionDown() { + let branchRow = this._selectedRow[0]; + let childRow = this._selectedRow[1]; + let branch = this.data[branchRow]; + + if (this.filter) { + this.selectRow(branchRow + 1); + return; + } + + if (branchRow < 0) { + this.selectRow(0, -1); + } else if ( + (!branch.tabs.length || + childRow >= branch.tabs.length - 1 || + !this._isOpen(branch)) && + branchRow < this.data.length + ) { + this.selectRow(branchRow + 1, -1); + } else if (childRow < branch.tabs.length) { + this.selectRow(branchRow, childRow + 1); + } + }, + + moveSelectionUp() { + let branchRow = this._selectedRow[0]; + let childRow = this._selectedRow[1]; + + if (this.filter) { + this.selectRow(branchRow - 1); + return; + } + + if (branchRow < 0) { + this.selectRow(0, -1); + } else if (childRow < 0 && branchRow > 0) { + let prevBranch = this.data[branchRow - 1]; + let newChildRow = this._isOpen(prevBranch) + ? prevBranch.tabs.length - 1 + : -1; + this.selectRow(branchRow - 1, newChildRow); + } else if (childRow >= 0) { + this.selectRow(branchRow, childRow - 1); + } + }, + + // Selects a row and makes sure the selection is within bounds + selectRow(parent, child) { + let maxParentRow = this.filter ? this._tabCount() : this.data.length; + let parentRow = parent; + if (parent <= -1) { + parentRow = 0; + } else if (parent >= maxParentRow) { + return; + } + + let childRow = child; + if ( + parentRow === -1 || + this.filter || + typeof child === "undefined" || + child < -1 + ) { + childRow = -1; + } else if (child >= this.data[parentRow].tabs.length) { + childRow = this.data[parentRow].tabs.length - 1; + } + + if ( + this._selectedRow[0] === parentRow && + this._selectedRow[1] === childRow + ) { + return; + } + + this._selectedRow = [parentRow, childRow]; + this.inputFocused = false; + this._change("all"); + // Record the telemetry event + let extraOptions = { + tab_pos: this._selectedRow[1].toString(), + filter: this.filter, + }; + this._SyncedTabs.recordSyncedTabsTelemetry( + "synced_tabs_sidebar", + "click", + extraOptions + ); + }, + + _tabCount() { + return this.data.reduce((prev, curr) => curr.tabs.length + prev, 0); + }, + + toggleBranch(id) { + this._toggleBranch(id, !this._closedClients[id]); + }, + + closeBranch(id) { + this._toggleBranch(id, true); + }, + + openBranch(id) { + this._toggleBranch(id, false); + }, + + focusInput() { + this.inputFocused = true; + // A change type of "all" updates rather than rebuilds, which is what we + // want here - only the selection/focus has changed. + this._change("all"); + }, + + blurInput() { + this.inputFocused = false; + // A change type of "all" updates rather than rebuilds, which is what we + // want here - only the selection/focus has changed. + this._change("all"); + }, + + clearFilter() { + this.filter = ""; + this._selectedRow = [-1, -1]; + return this.getData(); + }, + + // Fetches data from the SyncedTabs module and triggers + // and update + getData(filter) { + let updateType; + let hasFilter = typeof filter !== "undefined"; + if (hasFilter) { + this.filter = filter; + this._selectedRow = [-1, -1]; + + // When a filter is specified we tell the view that only the list + // needs to be rerendered so that it doesn't disrupt the input + // field's focus. + updateType = "searchbox"; + } + + // return promise for tests + return this._SyncedTabs + .getTabClients(this.filter) + .then(result => { + if (!hasFilter) { + // Only sort clients and tabs if we're rendering the whole list. + this._SyncedTabs.sortTabClientsByLastUsed(result); + } + this.data = result; + this._change(updateType); + }) + .catch(console.error); + }, +}); diff --git a/browser/components/syncedtabs/TabListComponent.sys.mjs b/browser/components/syncedtabs/TabListComponent.sys.mjs new file mode 100644 index 0000000000..cda3376dc2 --- /dev/null +++ b/browser/components/syncedtabs/TabListComponent.sys.mjs @@ -0,0 +1,147 @@ +/* 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/. */ + +let log = ChromeUtils.importESModule( + "resource://gre/modules/Log.sys.mjs" +).Log.repository.getLogger("Sync.RemoteTabs"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.sys.mjs", +}); + +/** + * TabListComponent + * + * The purpose of this component is to compose the view, state, and actions. + * It defines high level actions that act on the state and passes them to the + * view for it to trigger during user interaction. It also subscribes the view + * to state changes so it can rerender. + */ + +export function TabListComponent({ + window, + store, + View, + SyncedTabs, + clipboardHelper, + getChromeWindow, +}) { + this._window = window; + this._store = store; + this._View = View; + this._clipboardHelper = clipboardHelper; + this._getChromeWindow = getChromeWindow; + // used to trigger Sync from context menu + this._SyncedTabs = SyncedTabs; +} + +TabListComponent.prototype = { + get container() { + return this._view.container; + }, + + init() { + log.debug("Initializing TabListComponent"); + + this._view = new this._View(this._window, { + onSelectRow: (...args) => this.onSelectRow(...args), + onOpenTab: (...args) => this.onOpenTab(...args), + onOpenTabs: (...args) => this.onOpenTabs(...args), + onMoveSelectionDown: (...args) => this.onMoveSelectionDown(...args), + onMoveSelectionUp: (...args) => this.onMoveSelectionUp(...args), + onToggleBranch: (...args) => this.onToggleBranch(...args), + onBookmarkTab: (...args) => this.onBookmarkTab(...args), + onCopyTabLocation: (...args) => this.onCopyTabLocation(...args), + onSyncRefresh: (...args) => this.onSyncRefresh(...args), + onFilter: (...args) => this.onFilter(...args), + onClearFilter: (...args) => this.onClearFilter(...args), + onFilterFocus: (...args) => this.onFilterFocus(...args), + onFilterBlur: (...args) => this.onFilterBlur(...args), + }); + + this._store.on("change", state => this._view.render(state)); + this._view.render({ clients: [] }); + // get what's already available... + this._store.getData(); + this._store.focusInput(); + }, + + uninit() { + this._view.destroy(); + }, + + onFilter(query) { + this._store.getData(query); + }, + + onClearFilter() { + this._store.clearFilter(); + }, + + onFilterFocus() { + this._store.focusInput(); + }, + + onFilterBlur() { + this._store.blurInput(); + }, + + onSelectRow(position) { + this._store.selectRow(position[0], position[1]); + }, + + onMoveSelectionDown() { + this._store.moveSelectionDown(); + }, + + onMoveSelectionUp() { + this._store.moveSelectionUp(); + }, + + onToggleBranch(id) { + this._store.toggleBranch(id); + }, + + onBookmarkTab(uri, title) { + this._window.top.PlacesCommandHook.bookmarkLink(uri, title).catch( + console.error + ); + }, + + onOpenTab(url, where, params) { + this._window.openTrustedLinkIn(url, where, params); + }, + + onOpenTabs(urls, where) { + if (!lazy.OpenInTabsUtils.confirmOpenInTabs(urls.length, this._window)) { + return; + } + if (where == "window") { + this._window.openDialog( + this._window.AppConstants.BROWSER_CHROME_URL, + "_blank", + "chrome,dialog=no,all", + urls.join("|") + ); + } else { + let loadInBackground = where == "tabshifted"; + this._getChromeWindow(this._window).gBrowser.loadTabs(urls, { + inBackground: loadInBackground, + replace: false, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + } + }, + + onCopyTabLocation(url) { + this._clipboardHelper.copyString(url); + }, + + onSyncRefresh() { + this._SyncedTabs.syncTabs(true); + }, +}; diff --git a/browser/components/syncedtabs/TabListView.sys.mjs b/browser/components/syncedtabs/TabListView.sys.mjs new file mode 100644 index 0000000000..a280a0fe0f --- /dev/null +++ b/browser/components/syncedtabs/TabListView.sys.mjs @@ -0,0 +1,657 @@ +/* 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", +}); + +import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs"; + +function getContextMenu(window) { + return getChromeWindow(window).document.getElementById( + "SyncedTabsSidebarContext" + ); +} + +function getTabsFilterContextMenu(window) { + return getChromeWindow(window).document.getElementById( + "SyncedTabsSidebarTabsFilterContext" + ); +} + +/* + * TabListView + * + * Given a state, this object will render the corresponding DOM. + * It maintains no state of it's own. It listens for DOM events + * and triggers actions that may cause the state to change and + * ultimately the view to rerender. + */ +export function TabListView(window, props) { + this.props = props; + + this._window = window; + this._doc = this._window.document; + + this._tabsContainerTemplate = this._doc.getElementById( + "tabs-container-template" + ); + this._clientTemplate = this._doc.getElementById("client-template"); + this._emptyClientTemplate = this._doc.getElementById("empty-client-template"); + this._tabTemplate = this._doc.getElementById("tab-template"); + this.tabsFilter = this._doc.querySelector(".tabsFilter"); + + this.container = this._doc.createElement("div"); + + this._attachFixedListeners(); + + this._setupContextMenu(); +} + +TabListView.prototype = { + render(state) { + // Don't rerender anything; just update attributes, e.g. selection + if (state.canUpdateAll) { + this._update(state); + return; + } + // Rerender the tab list + if (state.canUpdateInput) { + this._updateSearchBox(state); + this._createList(state); + return; + } + // Create the world anew + this._create(state); + }, + + // Create the initial DOM from templates + _create(state) { + let wrapper = this._doc.importNode( + this._tabsContainerTemplate.content, + true + ).firstElementChild; + this._clearChilden(); + this.container.appendChild(wrapper); + + this.list = this.container.querySelector(".list"); + + this._createList(state); + this._updateSearchBox(state); + + this._attachListListeners(); + }, + + _createList(state) { + this._clearChilden(this.list); + for (let client of state.clients) { + if (state.filter) { + this._renderFilteredClient(client); + } else { + this._renderClient(client); + } + } + if (this.list.firstElementChild) { + const firstTab = this.list.firstElementChild.querySelector( + ".item.tab:first-child .item-title" + ); + if (firstTab) { + firstTab.setAttribute("tabindex", 2); + } + } + }, + + destroy() { + this._teardownContextMenu(); + this.container.remove(); + }, + + _update(state) { + this._updateSearchBox(state); + for (let client of state.clients) { + let clientNode = this._doc.getElementById("item-" + client.id); + if (clientNode) { + this._updateClient(client, clientNode); + } + + client.tabs.forEach((tab, index) => { + let tabNode = this._doc.getElementById( + "tab-" + client.id + "-" + index + ); + this._updateTab(tab, tabNode, index); + }); + } + }, + + // Client rows are hidden when the list is filtered + _renderFilteredClient(client, filter) { + client.tabs.forEach((tab, index) => { + let node = this._renderTab(client, tab, index); + this.list.appendChild(node); + }); + }, + + _updateLastSyncTitle(lastModified, itemNode) { + let lastSync = new Date(lastModified); + let lastSyncTitle = getChromeWindow(this._window).gSync.formatLastSyncDate( + lastSync + ); + itemNode.setAttribute("title", lastSyncTitle); + }, + + _renderClient(client) { + let itemNode = client.tabs.length + ? this._createClient(client) + : this._createEmptyClient(client); + + itemNode.addEventListener("mouseover", () => + this._updateLastSyncTitle(client.lastModified, itemNode) + ); + + this._updateClient(client, itemNode); + + let tabsList = itemNode.querySelector(".item-tabs-list"); + client.tabs.forEach((tab, index) => { + let node = this._renderTab(client, tab, index); + tabsList.appendChild(node); + }); + + this.list.appendChild(itemNode); + return itemNode; + }, + + _renderTab(client, tab, index) { + let itemNode = this._createTab(tab); + this._updateTab(tab, itemNode, index); + return itemNode; + }, + + _createClient() { + return this._doc.importNode(this._clientTemplate.content, true) + .firstElementChild; + }, + + _createEmptyClient() { + return this._doc.importNode(this._emptyClientTemplate.content, true) + .firstElementChild; + }, + + _createTab() { + return this._doc.importNode(this._tabTemplate.content, true) + .firstElementChild; + }, + + _clearChilden(node) { + let parent = node || this.container; + while (parent.firstChild) { + parent.firstChild.remove(); + } + }, + + // These listeners are attached only once, when we initialize the view + _attachFixedListeners() { + this.tabsFilter.addEventListener("command", this.onFilter.bind(this)); + this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this)); + this.tabsFilter.addEventListener("blur", this.onFilterBlur.bind(this)); + }, + + // These listeners have to be re-created every time since we re-create the list + _attachListListeners() { + this.list.addEventListener("click", this.onClick.bind(this)); + this.list.addEventListener("mouseup", this.onMouseUp.bind(this)); + this.list.addEventListener("keydown", this.onKeyDown.bind(this)); + }, + + _updateSearchBox(state) { + this.tabsFilter.value = state.filter; + if (state.inputFocused) { + this.tabsFilter.focus(); + } + }, + + /** + * Update the element representing an item, ensuring it's in sync with the + * underlying data. + * + * @param {client} item - Item to use as a source. + * @param {Element} itemNode - Element to update. + */ + _updateClient(item, itemNode) { + itemNode.setAttribute("id", "item-" + item.id); + this._updateLastSyncTitle(item.lastModified, itemNode); + if (item.closed) { + itemNode.classList.add("closed"); + } else { + itemNode.classList.remove("closed"); + } + if (item.selected) { + itemNode.classList.add("selected"); + } else { + itemNode.classList.remove("selected"); + } + if (item.focused) { + itemNode.focus(); + } + itemNode.setAttribute("clientType", item.clientType); + itemNode.dataset.id = item.id; + itemNode.querySelector(".item-title").textContent = item.name; + }, + + /** + * Update the element representing a tab, ensuring it's in sync with the + * underlying data. + * + * @param {tab} item - Item to use as a source. + * @param {Element} itemNode - Element to update. + */ + _updateTab(item, itemNode, index) { + itemNode.setAttribute("title", `${item.title}\n${item.url}`); + itemNode.setAttribute("id", "tab-" + item.client + "-" + index); + if (item.selected) { + itemNode.classList.add("selected"); + } else { + itemNode.classList.remove("selected"); + } + if (item.focused) { + itemNode.focus(); + } + itemNode.dataset.url = item.url; + + itemNode.querySelector(".item-title").textContent = item.title; + + if (item.icon) { + let icon = itemNode.querySelector(".item-icon-container"); + icon.style.backgroundImage = "url(" + item.icon + ")"; + } + }, + + onMouseUp(event) { + if (event.which == 2) { + // Middle click + this.onClick(event); + } + }, + + onClick(event) { + let itemNode = this._findParentItemNode(event.target); + if (!itemNode) { + return; + } + + if (itemNode.classList.contains("tab")) { + let url = itemNode.dataset.url; + if (url) { + this.onOpenSelected(url, event); + } + } + + // Middle click on a client + if (itemNode.classList.contains("client")) { + let where = getChromeWindow(this._window).whereToOpenLink(event); + if (where != "current") { + this._openAllClientTabs(itemNode, where); + } + } + + if ( + event.target.classList.contains("item-twisty-container") && + event.which != 2 + ) { + this.props.onToggleBranch(itemNode.dataset.id); + return; + } + + let position = this._getSelectionPosition(itemNode); + this.props.onSelectRow(position); + }, + + /** + * Handle a keydown event on the list box. + * + * @param {Event} event - Triggering event. + */ + onKeyDown(event) { + if (event.keyCode == this._window.KeyEvent.DOM_VK_DOWN) { + event.preventDefault(); + this.props.onMoveSelectionDown(); + } else if (event.keyCode == this._window.KeyEvent.DOM_VK_UP) { + event.preventDefault(); + this.props.onMoveSelectionUp(); + } else if (event.keyCode == this._window.KeyEvent.DOM_VK_RETURN) { + let selectedNode = this.container.querySelector(".item.selected"); + if (selectedNode.dataset.url) { + this.onOpenSelected(selectedNode.dataset.url, event); + } else if (selectedNode) { + this.props.onToggleBranch(selectedNode.dataset.id); + } + } + }, + + onBookmarkTab() { + let item = this._getSelectedTabNode(); + if (item) { + let title = item.querySelector(".item-title").textContent; + this.props.onBookmarkTab(item.dataset.url, title); + } + }, + + onCopyTabLocation() { + let item = this._getSelectedTabNode(); + if (item) { + this.props.onCopyTabLocation(item.dataset.url); + } + }, + + onOpenSelected(url, event) { + let where = getChromeWindow(this._window).whereToOpenLink(event); + this.props.onOpenTab(url, where, {}); + }, + + onOpenSelectedFromContextMenu(event) { + let item = this._getSelectedTabNode(); + if (item) { + let where = event.target.getAttribute("where"); + let params = { + private: event.target.hasAttribute("private"), + }; + this.props.onOpenTab(item.dataset.url, where, params); + } + }, + + onOpenSelectedInContainerTab(event) { + let item = this._getSelectedTabNode(); + if (item) { + this.props.onOpenTab(item.dataset.url, "tab", { + userContextId: parseInt(event.target?.dataset.usercontextid), + }); + } + }, + + onOpenAllInTabs() { + let item = this._getSelectedClientNode(); + if (item) { + this._openAllClientTabs(item, "tab"); + } + }, + + onFilter(event) { + let query = event.target.value; + if (query) { + this.props.onFilter(query); + } else { + this.props.onClearFilter(); + } + }, + + onFilterFocus() { + this.props.onFilterFocus(); + }, + onFilterBlur() { + this.props.onFilterBlur(); + }, + + _getSelectedTabNode() { + let item = this.container.querySelector(".item.selected"); + if (this._isTab(item) && item.dataset.url) { + return item; + } + return null; + }, + + _getSelectedClientNode() { + let item = this.container.querySelector(".item.selected"); + if (this._isClient(item)) { + return item; + } + return null; + }, + + // Set up the custom context menu + _setupContextMenu() { + Services.els.addSystemEventListener( + this._window, + "contextmenu", + this, + false + ); + for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) { + let menu = getMenu(this._window); + menu.addEventListener("popupshowing", this, true); + menu.addEventListener("command", this, true); + } + }, + + _teardownContextMenu() { + // Tear down context menu + Services.els.removeSystemEventListener( + this._window, + "contextmenu", + this, + false + ); + for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) { + let menu = getMenu(this._window); + menu.removeEventListener("popupshowing", this, true); + menu.removeEventListener("command", this, true); + } + }, + + handleEvent(event) { + switch (event.type) { + case "contextmenu": + this.handleContextMenu(event); + break; + + case "popupshowing": { + if ( + event.target.getAttribute("id") == + "SyncedTabsSidebarTabsFilterContext" + ) { + this.handleTabsFilterContextMenuShown(event); + } + break; + } + + case "command": { + let menu = event.target.closest("menupopup"); + switch (menu.getAttribute("id")) { + case "SyncedTabsSidebarContext": + this.handleContentContextMenuCommand(event); + break; + + case "SyncedTabsOpenSelectedInContainerTabMenu": + this.onOpenSelectedInContainerTab(event); + break; + + case "SyncedTabsSidebarTabsFilterContext": + this.handleTabsFilterContextMenuCommand(event); + break; + } + break; + } + } + }, + + handleTabsFilterContextMenuShown(event) { + let document = event.target.ownerDocument; + let focusedElement = document.commandDispatcher.focusedElement; + if (focusedElement != this.tabsFilter.inputField) { + this.tabsFilter.focus(); + } + for (let item of event.target.children) { + if (!item.hasAttribute("cmd")) { + continue; + } + let command = item.getAttribute("cmd"); + let controller = + document.commandDispatcher.getControllerForCommand(command); + if (controller.isCommandEnabled(command)) { + item.removeAttribute("disabled"); + } else { + item.setAttribute("disabled", "true"); + } + } + }, + + handleContentContextMenuCommand(event) { + let id = event.target.getAttribute("id"); + switch (id) { + case "syncedTabsOpenSelected": + case "syncedTabsOpenSelectedInTab": + case "syncedTabsOpenSelectedInWindow": + case "syncedTabsOpenSelectedInPrivateWindow": + this.onOpenSelectedFromContextMenu(event); + break; + case "syncedTabsOpenAllInTabs": + this.onOpenAllInTabs(); + break; + case "syncedTabsBookmarkSelected": + this.onBookmarkTab(); + break; + case "syncedTabsCopySelected": + this.onCopyTabLocation(); + break; + case "syncedTabsRefresh": + case "syncedTabsRefreshFilter": + this.props.onSyncRefresh(); + break; + } + }, + + handleTabsFilterContextMenuCommand(event) { + let command = event.target.getAttribute("cmd"); + let dispatcher = getChromeWindow(this._window).document.commandDispatcher; + let controller = + dispatcher.focusedElement.controllers.getControllerForCommand(command); + controller.doCommand(command); + }, + + handleContextMenu(event) { + let menu; + + if (event.target == this.tabsFilter) { + menu = getTabsFilterContextMenu(this._window); + } else { + let itemNode = this._findParentItemNode(event.target); + if (itemNode) { + let position = this._getSelectionPosition(itemNode); + this.props.onSelectRow(position); + } + menu = getContextMenu(this._window); + this.adjustContextMenu(menu); + } + + menu.openPopupAtScreen(event.screenX, event.screenY, true, event); + }, + + adjustContextMenu(menu) { + let item = this.container.querySelector(".item.selected"); + let showTabOptions = this._isTab(item); + + let el = menu.firstElementChild; + + while (el) { + let show = false; + if (showTabOptions) { + if (el.getAttribute("id") == "syncedTabsOpenSelectedInPrivateWindow") { + show = lazy.PrivateBrowsingUtils.enabled; + } else if ( + el.getAttribute("id") === "syncedTabsOpenSelectedInContainerTab" + ) { + show = + Services.prefs.getBoolPref("privacy.userContext.enabled", false) && + !lazy.PrivateBrowsingUtils.isWindowPrivate( + getChromeWindow(this._window) + ); + } else if ( + el.getAttribute("id") != "syncedTabsOpenAllInTabs" && + el.getAttribute("id") != "syncedTabsManageDevices" + ) { + show = true; + } + } else if (el.getAttribute("id") == "syncedTabsOpenAllInTabs") { + const tabs = item.querySelectorAll(".item-tabs-list > .item.tab"); + show = !!tabs.length; + } else if (el.getAttribute("id") == "syncedTabsRefresh") { + show = true; + } else if (el.getAttribute("id") == "syncedTabsManageDevices") { + show = true; + } + el.hidden = !show; + + el = el.nextElementSibling; + } + }, + + /** + * Find the parent item element, from a given child element. + * + * @param {Element} node - Child element. + * @returns {Element} Element for the item, or null if not found. + */ + _findParentItemNode(node) { + while ( + node && + node !== this.list && + node !== this._doc.documentElement && + !node.classList.contains("item") + ) { + node = node.parentNode; + } + + if (node !== this.list && node !== this._doc.documentElement) { + return node; + } + + return null; + }, + + _findParentBranchNode(node) { + while ( + node && + !node.classList.contains("list") && + node !== this._doc.documentElement && + !node.parentNode.classList.contains("list") + ) { + node = node.parentNode; + } + + if (node !== this.list && node !== this._doc.documentElement) { + return node; + } + + return null; + }, + + _getSelectionPosition(itemNode) { + let parent = this._findParentBranchNode(itemNode); + let parentPosition = this._indexOfNode(parent.parentNode, parent); + let childPosition = -1; + // if the node is not a client, find its position within the parent + if (parent !== itemNode) { + childPosition = this._indexOfNode(itemNode.parentNode, itemNode); + } + return [parentPosition, childPosition]; + }, + + _indexOfNode(parent, child) { + return Array.prototype.indexOf.call(parent.children, child); + }, + + _isTab(item) { + return item && item.classList.contains("tab"); + }, + + _isClient(item) { + return item && item.classList.contains("client"); + }, + + _openAllClientTabs(clientNode, where) { + const tabs = clientNode.querySelector(".item-tabs-list").children; + const urls = [...tabs].map(tab => tab.dataset.url); + this.props.onOpenTabs(urls, where); + }, +}; diff --git a/browser/components/syncedtabs/jar.mn b/browser/components/syncedtabs/jar.mn new file mode 100644 index 0000000000..ba2b105a17 --- /dev/null +++ b/browser/components/syncedtabs/jar.mn @@ -0,0 +1,7 @@ +# 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/syncedtabs/sidebar.xhtml + content/browser/syncedtabs/sidebar.js diff --git a/browser/components/syncedtabs/moz.build b/browser/components/syncedtabs/moz.build new file mode 100644 index 0000000000..8a702d3906 --- /dev/null +++ b/browser/components/syncedtabs/moz.build @@ -0,0 +1,23 @@ +# 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"] + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +EXTRA_JS_MODULES.syncedtabs += [ + "EventEmitter.sys.mjs", + "SyncedTabsDeckComponent.sys.mjs", + "SyncedTabsDeckStore.sys.mjs", + "SyncedTabsDeckView.sys.mjs", + "SyncedTabsListStore.sys.mjs", + "TabListComponent.sys.mjs", + "TabListView.sys.mjs", + "util.sys.mjs", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Sync") diff --git a/browser/components/syncedtabs/sidebar.js b/browser/components/syncedtabs/sidebar.js new file mode 100644 index 0000000000..bf65bd96d5 --- /dev/null +++ b/browser/components/syncedtabs/sidebar.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +const { SyncedTabsDeckComponent } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsDeckComponent.sys.mjs" +); + +var syncedTabsDeckComponent = new SyncedTabsDeckComponent({ + window, + SyncedTabs, +}); + +let onLoaded = () => { + window.top.MozXULElement.insertFTLIfNeeded("browser/syncedTabs.ftl"); + window.top.document + .getElementById("SyncedTabsSidebarContext") + .querySelectorAll("[data-lazy-l10n-id]") + .forEach(el => { + el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); + el.removeAttribute("data-lazy-l10n-id"); + }); + syncedTabsDeckComponent.init(); + document + .getElementById("template-container") + .appendChild(syncedTabsDeckComponent.container); +}; + +let onUnloaded = () => { + removeEventListener("DOMContentLoaded", onLoaded); + removeEventListener("unload", onUnloaded); + syncedTabsDeckComponent.uninit(); +}; + +addEventListener("DOMContentLoaded", onLoaded); +addEventListener("unload", onUnloaded); diff --git a/browser/components/syncedtabs/sidebar.xhtml b/browser/components/syncedtabs/sidebar.xhtml new file mode 100644 index 0000000000..8091f61aee --- /dev/null +++ b/browser/components/syncedtabs/sidebar.xhtml @@ -0,0 +1,157 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!DOCTYPE html> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" +> + <head> + <script src="chrome://browser/content/syncedtabs/sidebar.js" /> + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://browser/content/contentTheme.js" /> + + <link + rel="stylesheet" + media="all" + href="chrome://browser/skin/syncedtabs/sidebar.css" + /> + <link rel="localization" href="browser/syncedTabs.ftl" /> + <link rel="localization" href="toolkit/branding/accounts.ftl" /> + <title data-l10n-id="synced-tabs-sidebar-title" /> + </head> + + <body role="application"> + <template id="client-template"> + <div class="item client" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-twisty-container"></div> + <div class="item-icon-container"></div> + <p class="item-title"></p> + </div> + <div class="item-tabs-list"></div> + </div> + </template> + <template id="empty-client-template"> + <div class="item empty client" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-twisty-container"></div> + <div class="item-icon-container"></div> + <p class="item-title"></p> + </div> + <div class="item-tabs-list"> + <div class="item empty" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-icon-container"></div> + <p + class="item-title" + data-l10n-id="synced-tabs-sidebar-notabs" + ></p> + </div> + </div> + </div> + </div> + </template> + <template id="tab-template"> + <div class="item tab" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-icon-container"></div> + <p class="item-title"></p> + </div> + </div> + </template> + + <template id="tabs-container-template"> + <div class="tabs-container"> + <div class="list" role="listbox"></div> + </div> + </template> + + <template id="deck-template"> + <div class="deck"> + <div class="tabs-fetching sync-state"> + <!-- Show intentionally blank panel, see bug 1239845 --> + </div> + <div class="notAuthedInfo sync-state"> + <div class="syncIllustration"></div> + <p class="instructions" data-l10n-id="synced-tabs-sidebar-intro"></p> + <button + class="button sync-prefs" + data-l10n-id="synced-tabs-fxa-sign-in" + ></button> + </div> + <div class="syncDisabled sync-state"> + <div class="syncIllustration"></div> + <p class="instructions" data-l10n-id="synced-tabs-sidebar-intro"></p> + <button + class="button sync-prefs" + data-l10n-id="synced-tabs-turn-on-sync" + ></button> + </div> + <div class="reauth sync-state"> + <div class="syncIllustrationIssue"></div> + <p class="instructions" data-l10n-id="synced-tabs-sidebar-intro"></p> + <button + class="button sync-prefs" + data-l10n-id="synced-tabs-fxa-sign-in" + ></button> + </div> + <div class="unverified sync-state"> + <div class="syncIllustrationIssue"></div> + <p + class="instructions" + data-l10n-id="synced-tabs-sidebar-unverified" + ></p> + <button + class="button sync-prefs" + data-l10n-id="synced-tabs-sidebar-open-settings" + ></button> + </div> + <div class="singleDeviceInfo sync-state"> + <div class="syncIllustrationIssue"></div> + <p + class="instructions" + data-l10n-id="synced-tabs-sidebar-noclients-subtitle" + ></p> + <button + class="button connect-device" + data-l10n-id="synced-tabs-sidebar-connect-another-device" + ></button> + </div> + <div class="tabs-disabled sync-state"> + <div class="syncIllustrationIssue"></div> + <p + class="instructions" + data-l10n-id="synced-tabs-sidebar-tabsnotsyncing" + ></p> + <button + class="button sync-prefs" + data-l10n-id="synced-tabs-sidebar-open-settings" + ></button> + </div> + </div> + </template> + + <div class="content-container"> + <!-- the non-scrollable header --> + <div class="content-header"> + <div class="sidebar-search-container tabs-container sync-state"> + <xul:search-textbox + class="tabsFilter" + tabindex="1" + data-l10n-id="synced-tabs-sidebar-search" + data-l10n-attrs="placeholder" + /> + </div> + </div> + <!-- the scrollable content area where our templates are inserted --> + <div + id="template-container" + class="content-scrollable" + tabindex="-1" + ></div> + </div> + </body> +</html> diff --git a/browser/components/syncedtabs/test/browser/browser.toml b/browser/components/syncedtabs/test/browser/browser.toml new file mode 100644 index 0000000000..eb8176f0a6 --- /dev/null +++ b/browser/components/syncedtabs/test/browser/browser.toml @@ -0,0 +1,4 @@ +[DEFAULT] +support-files = ["head.js"] + +["browser_sidebar_syncedtabslist.js"] diff --git a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js new file mode 100644 index 0000000000..daf980e5fa --- /dev/null +++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js @@ -0,0 +1,646 @@ +"use strict"; + +const { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +const FIXTURE = [ + { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "zcarter's Nightly on MacBook-Pro-25", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: + "Firefox for Android — Mobile Web browser — More ways to customize and protect your privacy — Mozilla", + url: "https://www.mozilla.org/en-US/firefox/android/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar", + icon: "chrome://global/skin/icons/defaultFavicon.svg", + client: "7cqCr77ptzX3", + lastUsed: 1452124677, + }, + ], + }, + { + id: "2xU5h-4bkWqA", + type: "client", + lastModified: 1492201200, + name: "laptop", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: + "Firefox for iOS — Mobile Web browser for your iPhone, iPad and iPod touch — Mozilla", + url: "https://www.mozilla.org/en-US/firefox/ios/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar", + icon: "cached-favicon:https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico", + client: "2xU5h-4bkWqA", + lastUsed: 1451519425, + }, + { + type: "tab", + title: "Firefox Nightly First Run Page", + url: "https://www.mozilla.org/en-US/firefox/nightly/firstrun/?oldversion=45.0a1", + icon: "cached-favicon:https://www.mozilla.org/media/img/firefox/favicon-nightly.560395bbb2e1.png", + client: "2xU5h-4bkWqA", + lastUsed: 1451519420, + }, + { + // Should appear first for this client. + type: "tab", + title: "Mozilla Developer Network", + url: "https://developer.mozilla.org/en-US/", + icon: "cached-favicon:https://developer.cdn.mozilla.net/static/img/favicon32.e02854fdcf73.png", + client: "2xU5h-4bkWqA", + lastUsed: 1451519725, + }, + ], + }, + { + id: "OL3EJCsdb2JD", + type: "client", + lastModified: 1492201200, + name: "desktop", + clientType: "desktop", + tabs: [], + }, +]; + +function setupSyncedTabsStubs({ + uiState = { status: UIState.STATUS_SIGNED_IN, syncEnabled: true }, + isConfiguredToSyncTabs = true, + hasSyncedThisSession = true, + tabClients = Cu.cloneInto(FIXTURE, {}), +} = {}) { + sinon.stub(UIState, "get").returns(uiState); + sinon.stub(SyncedTabs._internal, "getTabClients").resolves(tabClients); + sinon.stub(SyncedTabs._internal, "syncTabs").resolves(); + sinon + .stub(SyncedTabs._internal, "isConfiguredToSyncTabs") + .value(isConfiguredToSyncTabs); + sinon + .stub(SyncedTabs._internal, "hasSyncedThisSession") + .value(hasSyncedThisSession); +} + +async function testClean() { + sinon.restore(); + await new Promise(resolve => { + window.SidebarUI.browser.contentWindow.addEventListener( + "unload", + function () { + resolve(); + }, + { once: true } + ); + SidebarUI.hide(); + }); +} + +add_task(async function testSyncedTabsSidebarList() { + await SidebarUI.show("viewTabsSidebar"); + + Assert.equal( + SidebarUI.currentID, + "viewTabsSidebar", + "Sidebar should have SyncedTabs loaded" + ); + + let syncedTabsDeckComponent = + SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + setupSyncedTabsStubs(); + + await syncedTabsDeckComponent.updatePanel(); + // This is a hacky way of waiting for the view to render. The view renders + // after the following promise (a different instance of which is triggered + // in updatePanel) resolves, so we wait for it here as well + await syncedTabsDeckComponent.tabListComponent._store.getData(); + + Assert.ok(SyncedTabs._internal.getTabClients.called, "get clients called"); + + let selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + + Assert.ok( + selectedPanel.classList.contains("tabs-container"), + "tabs panel is selected" + ); + + Assert.equal( + selectedPanel.querySelectorAll(".tab").length, + 4, + "four tabs listed" + ); + Assert.equal( + selectedPanel.querySelectorAll(".client").length, + 3, + "three clients listed" + ); + Assert.equal( + selectedPanel.querySelectorAll(".client")[2].querySelectorAll(".empty") + .length, + 1, + "third client is empty" + ); + + // Verify that the tabs are sorted by last used time. + var expectedTabIndices = [[0], [2, 0, 1]]; + Array.prototype.forEach.call( + selectedPanel.querySelectorAll(".client"), + (clientNode, i) => { + checkItem(clientNode, FIXTURE[i]); + Array.prototype.forEach.call( + clientNode.querySelectorAll(".tab"), + (tabNode, j) => { + let tabIndex = expectedTabIndices[i][j]; + checkItem(tabNode, FIXTURE[i].tabs[tabIndex]); + } + ); + } + ); +}); + +add_task(testClean); + +add_task(async function testSyncedTabsSidebarFilteredList() { + await SidebarUI.show("viewTabsSidebar"); + let syncedTabsDeckComponent = + window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + setupSyncedTabsStubs(); + + await syncedTabsDeckComponent.updatePanel(); + + let filterInput = + syncedTabsDeckComponent._window.document.querySelector(".tabsFilter"); + filterInput.value = "filter text"; + filterInput.blur(); + + // This is a hacky way of waiting for the view to render. The view renders + // after the following promise (a different instance of which is triggered + // in updatePanel) resolves, so we wait for it here as well + await syncedTabsDeckComponent.tabListComponent._store.getData("filter text"); + + let selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("tabs-container"), + "tabs panel is selected" + ); + + Assert.equal( + selectedPanel.querySelectorAll(".tab").length, + 4, + "four tabs listed" + ); + Assert.equal( + selectedPanel.querySelectorAll(".client").length, + 0, + "no clients are listed" + ); + + Assert.equal( + filterInput.value, + "filter text", + "filter text box has correct value" + ); + + // Tabs should not be sorted when filter is active. + let FIXTURE_TABS = FIXTURE.reduce( + (prev, client) => prev.concat(client.tabs), + [] + ); + + Array.prototype.forEach.call( + selectedPanel.querySelectorAll(".tab"), + (tabNode, i) => { + checkItem(tabNode, FIXTURE_TABS[i]); + } + ); + + // Removing the filter should resort tabs. + FIXTURE_TABS.sort((a, b) => b.lastUsed - a.lastUsed); + await syncedTabsDeckComponent.tabListComponent._store.getData(); + Array.prototype.forEach.call( + selectedPanel.querySelectorAll(".tab"), + (tabNode, i) => { + checkItem(tabNode, FIXTURE_TABS[i]); + } + ); +}); + +add_task(testClean); + +add_task(async function testSyncedTabsSidebarStatus() { + await SidebarUI.show("viewTabsSidebar"); + let syncedTabsDeckComponent = + window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + setupSyncedTabsStubs({ + uiState: { status: UIState.STATUS_NOT_CONFIGURED }, + isConfiguredToSyncTabs: false, + hasSyncedThisSession: false, + tabClients: [], + }); + await syncedTabsDeckComponent.updatePanel(); + let selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("notAuthedInfo"), + "not-authed panel is selected" + ); + sinon.restore(); + + setupSyncedTabsStubs({ + uiState: { status: UIState.STATUS_NOT_VERIFIED }, + isConfiguredToSyncTabs: false, + hasSyncedThisSession: false, + tabClients: [], + }); + await syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("unverified"), + "unverified panel is selected" + ); + sinon.restore(); + + setupSyncedTabsStubs({ + uiState: { status: UIState.STATUS_SIGNED_IN, syncEnabled: false }, + isConfiguredToSyncTabs: false, + hasSyncedThisSession: false, + tabClients: [], + }); + await syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("syncDisabled"), + "sync disabled panel is selected" + ); + sinon.restore(); + + setupSyncedTabsStubs({ + uiState: { status: UIState.STATUS_LOGIN_FAILED }, + isConfiguredToSyncTabs: false, + hasSyncedThisSession: false, + tabClients: [], + }); + await syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("reauth"), + "reauth panel is selected" + ); + sinon.restore(); + + setupSyncedTabsStubs({ + isConfiguredToSyncTabs: false, + hasSyncedThisSession: false, + tabClients: [], + }); + await syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("tabs-disabled"), + "tabs disabled panel is selected" + ); + sinon.restore(); + + setupSyncedTabsStubs({ + isConfiguredToSyncTabs: true, + hasSyncedThisSession: false, + tabClients: [], + }); + await syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("tabs-fetching"), + "tabs fetch panel is selected" + ); + sinon.restore(); + + setupSyncedTabsStubs({ + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + tabClients: [], + }); + await syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("singleDeviceInfo"), + "tabs fetch panel is selected" + ); + sinon.restore(); + + setupSyncedTabsStubs({ + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + tabClients: [{ id: "mock" }], + }); + await syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector( + ".sync-state.selected" + ); + Assert.ok( + selectedPanel.classList.contains("tabs-container"), + "tabs panel is selected" + ); +}); + +add_task(testClean); + +add_task(async function testSyncedTabsSidebarContextMenu() { + await SidebarUI.show("viewTabsSidebar"); + let syncedTabsDeckComponent = + window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + setupSyncedTabsStubs(); + + await syncedTabsDeckComponent.updatePanel(); + // This is a hacky way of waiting for the view to render. The view renders + // after the following promise (a different instance of which is triggered + // in updatePanel) resolves, so we wait for it here as well + await syncedTabsDeckComponent.tabListComponent._store.getData(); + + info("Right-clicking the search box should show text-related actions"); + let filterMenuItems = [ + "menuitem[cmd=cmd_undo]", + "menuseparator", + // We don't check whether the commands are enabled due to platform + // differences. On OS X and Windows, "cut" and "copy" are always enabled + // for HTML inputs; on Linux, they're only enabled if text is selected. + "menuitem[cmd=cmd_cut]", + "menuitem[cmd=cmd_copy]", + "menuitem[cmd=cmd_paste]", + "menuitem[cmd=cmd_delete]", + "menuseparator", + "menuitem[cmd=cmd_selectAll]", + "menuseparator", + "menuitem#syncedTabsRefreshFilter", + ]; + await testContextMenu( + syncedTabsDeckComponent, + "#SyncedTabsSidebarTabsFilterContext", + ".tabsFilter", + filterMenuItems + ); + + info("Right-clicking a tab should show additional actions"); + let tabMenuItems = [ + ["menuitem#syncedTabsOpenSelected", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInTab", { hidden: false }], + [ + "menu#syncedTabsOpenSelectedInContainerTab", + { + hidden: + !Services.prefs.getBoolPref("privacy.userContext.enabled", false) || + PrivateBrowsingUtils.isWindowPrivate(window), + }, + ], + ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: false }], + ["menuseparator", { hidden: false }], + ["menuitem#syncedTabsBookmarkSelected", { hidden: false }], + ["menuitem#syncedTabsCopySelected", { hidden: false }], + ["menuseparator", { hidden: false }], + ["menuitem#syncedTabsOpenAllInTabs", { hidden: true }], + ["menuitem#syncedTabsManageDevices", { hidden: true }], + ["menuitem#syncedTabsRefresh", { hidden: false }], + ]; + await testContextMenu( + syncedTabsDeckComponent, + "#SyncedTabsSidebarContext", + "#tab-7cqCr77ptzX3-0", + tabMenuItems + ); + + info( + "Right-clicking a client should show the Open All in Tabs and Manage devices actions" + ); + let sidebarMenuItems = [ + ["menuitem#syncedTabsOpenSelected", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInTab", { hidden: true }], + ["menu#syncedTabsOpenSelectedInContainerTab", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: true }], + ["menuseparator", { hidden: true }], + ["menuitem#syncedTabsBookmarkSelected", { hidden: true }], + ["menuitem#syncedTabsCopySelected", { hidden: true }], + ["menuseparator", { hidden: true }], + ["menuitem#syncedTabsOpenAllInTabs", { hidden: false }], + ["menuitem#syncedTabsManageDevices", { hidden: false }], + ["menuitem#syncedTabsRefresh", { hidden: false }], + ]; + await testContextMenu( + syncedTabsDeckComponent, + "#SyncedTabsSidebarContext", + "#item-7cqCr77ptzX3", + sidebarMenuItems + ); + + info( + "Right-clicking a client without any tabs should not show the Open All in Tabs action" + ); + let menuItems = [ + ["menuitem#syncedTabsOpenSelected", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInTab", { hidden: true }], + ["menu#syncedTabsOpenSelectedInContainerTab", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: true }], + ["menuseparator", { hidden: true }], + ["menuitem#syncedTabsBookmarkSelected", { hidden: true }], + ["menuitem#syncedTabsCopySelected", { hidden: true }], + ["menuseparator", { hidden: true }], + ["menuitem#syncedTabsOpenAllInTabs", { hidden: true }], + ["menuitem#syncedTabsManageDevices", { hidden: false }], + ["menuitem#syncedTabsRefresh", { hidden: false }], + ]; + await testContextMenu( + syncedTabsDeckComponent, + "#SyncedTabsSidebarContext", + "#item-OL3EJCsdb2JD", + menuItems + ); +}); + +add_task(testClean); + +function checkItem(node, item) { + Assert.ok(node.classList.contains("item"), "Node should have .item class"); + if (item.client) { + // tab items + Assert.equal( + node.querySelector(".item-title").textContent, + item.title, + "Node's title element's text should match item title" + ); + Assert.ok(node.classList.contains("tab"), "Node should have .tab class"); + Assert.equal( + node.dataset.url, + item.url, + "Node's URL should match item URL" + ); + Assert.equal( + node.getAttribute("title"), + item.title + "\n" + item.url, + "Tab node should have correct title attribute" + ); + } else { + // client items + Assert.equal( + node.querySelector(".item-title").textContent, + item.name, + "Node's title element's text should match client name" + ); + Assert.ok( + node.classList.contains("client"), + "Node should have .client class" + ); + Assert.equal(node.dataset.id, item.id, "Node's ID should match item ID"); + } +} + +async function testContextMenu( + syncedTabsDeckComponent, + contextSelector, + triggerSelector, + menuSelectors +) { + let contextMenu = document.querySelector(contextSelector); + let triggerElement = + syncedTabsDeckComponent._window.document.querySelector(triggerSelector); + let isClosed = triggerElement.classList.contains("closed"); + + let promisePopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + let chromeWindow = triggerElement.ownerGlobal.top; + let rect = triggerElement.getBoundingClientRect(); + let contentRect = chromeWindow.SidebarUI.browser.getBoundingClientRect(); + // The offsets in `rect` are relative to the content window, but + // `synthesizeMouseAtPoint` calls `nsIDOMWindowUtils.sendMouseEvent`, + // which interprets the offsets relative to the containing *chrome* window. + // This means we need to account for the width and height of any elements + // outside the `browser` element, like `#sidebar-header`. + let offsetX = contentRect.x + rect.x + rect.width / 2; + let offsetY = contentRect.y + rect.y + rect.height / 4; + + await EventUtils.synthesizeMouseAtPoint( + offsetX, + offsetY, + { + type: "contextmenu", + button: 2, + }, + chromeWindow + ); + await promisePopupShown; + is( + triggerElement.classList.contains("closed"), + isClosed, + "Showing the context menu shouldn't toggle the tab list" + ); + let menuitemClicked = await checkChildren(contextMenu, menuSelectors); + if (!menuitemClicked) { + let promisePopupHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await promisePopupHidden; + } +} + +function checkChildren(node, selectors) { + is(node.children.length, selectors.length, "Menu item count doesn't match"); + let containerMenuShown; + for (let index = 0; index < node.children.length; index++) { + let child = node.children[index]; + let [selector, props] = [].concat(selectors[index]); + ok(selector, `Node at ${index} should have selector`); + ok(child.matches(selector), `Node ${index} should match ${selector}`); + if (props) { + Object.keys(props).forEach(prop => { + is(child[prop], props[prop], `${prop} value at ${index} should match`); + }); + } + if ( + selector === "menu#syncedTabsOpenSelectedInContainerTab" && + !props.hidden + ) { + containerMenuShown = child; + } + } + if (containerMenuShown) { + return testContainerMenu(containerMenuShown); + } + return false; +} + +async function testContainerMenu(menu) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + let menupopup = menu.getElementsByTagName("menupopup")[0]; + let menupopupShown = BrowserTestUtils.waitForEvent(menupopup, "popupshown"); + menu.openMenu(true); + await menupopupShown; + let shown = [1, 2, 3, 4]; + let hidden = [0]; + for (let id of shown) { + ok( + menupopup.querySelector(`menuitem[data-usercontextid="${id}"]`), + `User context id ${id} should exist` + ); + } + for (let id of hidden) { + ok( + !menupopup.querySelector(`menuitem[data-usercontextid="${id}"]`), + `User context id ${id} shouldn't exist` + ); + } + const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + menupopup.activateItem( + menupopup.querySelector( + `menuitem[data-usercontextid="${shown[shown.length - 1]}"]` + ) + ); + let newTab = await newTabPromise; + ok( + newTab.hasAttribute("usercontextid"), + `Tab with usercontextid = ${shown[shown.length - 1]} should be opened` + ); + registerCleanupFunction(() => BrowserTestUtils.removeTab(newTab)); + return true; +} diff --git a/browser/components/syncedtabs/test/browser/head.js b/browser/components/syncedtabs/test/browser/head.js new file mode 100644 index 0000000000..bd90d22f03 --- /dev/null +++ b/browser/components/syncedtabs/test/browser/head.js @@ -0,0 +1,3 @@ +var { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); diff --git a/browser/components/syncedtabs/test/xpcshell/head.js b/browser/components/syncedtabs/test/xpcshell/head.js new file mode 100644 index 0000000000..3b79a09e8f --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/head.js @@ -0,0 +1,8 @@ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +do_get_profile(); // fxa needs a profile directory for storage. diff --git a/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js b/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js new file mode 100644 index 0000000000..07f2a1b296 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js @@ -0,0 +1,36 @@ +"use strict"; + +let { EventEmitter } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/EventEmitter.sys.mjs" +); + +add_task(async function testSingleListener() { + let eventEmitter = new EventEmitter(); + let spy = sinon.spy(); + + eventEmitter.on("click", spy); + eventEmitter.emit("click", "foo", "bar"); + Assert.ok(spy.calledOnce); + Assert.ok(spy.calledWith("foo", "bar")); + + eventEmitter.off("click", spy); + eventEmitter.emit("click"); + Assert.ok(spy.calledOnce); +}); + +add_task(async function testMultipleListeners() { + let eventEmitter = new EventEmitter(); + let spy1 = sinon.spy(); + let spy2 = sinon.spy(); + + eventEmitter.on("some_event", spy1); + eventEmitter.on("some_event", spy2); + eventEmitter.emit("some_event"); + Assert.ok(spy1.calledOnce); + Assert.ok(spy2.calledOnce); + + eventEmitter.off("some_event", spy1); + eventEmitter.emit("some_event"); + Assert.ok(spy1.calledOnce); + Assert.ok(spy2.calledTwice); +}); diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js new file mode 100644 index 0000000000..9162325081 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js @@ -0,0 +1,263 @@ +"use strict"; + +let { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +let { SyncedTabsDeckComponent } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsDeckComponent.sys.mjs" +); +let { SyncedTabsListStore } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs" +); +let { SyncedTabsDeckStore } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsDeckStore.sys.mjs" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +add_task(async function testInitUninit() { + let deckStore = new SyncedTabsDeckStore(); + let listComponent = {}; + let mockWindow = {}; + + let ViewMock = sinon.stub(); + let view = { render: sinon.spy(), destroy: sinon.spy(), container: {} }; + ViewMock.returns(view); + + sinon.stub(SyncedTabs, "syncTabs").callsFake(() => Promise.resolve()); + + sinon.spy(deckStore, "on"); + sinon.stub(deckStore, "setPanels"); + + let component = new SyncedTabsDeckComponent({ + window: mockWindow, + deckStore, + listComponent, + SyncedTabs, + DeckView: ViewMock, + }); + + sinon.stub(component, "updatePanel"); + + component.init(); + + Assert.ok(SyncedTabs.syncTabs.called); + SyncedTabs.syncTabs.restore(); + + Assert.ok(ViewMock.calledWithNew(), "view is instantiated"); + Assert.equal(ViewMock.args[0][0], mockWindow); + Assert.equal(ViewMock.args[0][1], listComponent); + Assert.ok( + ViewMock.args[0][2].onConnectDeviceClick, + "view is passed onConnectDeviceClick prop" + ); + Assert.ok( + ViewMock.args[0][2].onSyncPrefClick, + "view is passed onSyncPrefClick prop" + ); + + Assert.equal( + component.container, + view.container, + "component returns view's container" + ); + + Assert.ok(deckStore.on.calledOnce, "listener is added to store"); + Assert.equal(deckStore.on.args[0][0], "change"); + // Object.values only in nightly + let values = Object.keys(component.PANELS).map(k => component.PANELS[k]); + Assert.ok( + deckStore.setPanels.calledWith(values), + "panels are set on deck store" + ); + + Assert.ok(component.updatePanel.called); + + deckStore.emit("change", "mock state"); + Assert.ok( + view.render.calledWith("mock state"), + "view.render is called on state change" + ); + + component.uninit(); + + Assert.ok(view.destroy.calledOnce, "view is destroyed on uninit"); +}); + +add_task(async function testObserver() { + let deckStore = new SyncedTabsDeckStore(); + let listStore = new SyncedTabsListStore(SyncedTabs); + let listComponent = {}; + let mockWindow = {}; + + let ViewMock = sinon.stub(); + let view = { render: sinon.spy(), destroy: sinon.spy(), container: {} }; + ViewMock.returns(view); + + sinon.stub(SyncedTabs, "syncTabs").callsFake(() => Promise.resolve()); + + sinon.spy(deckStore, "on"); + sinon.stub(deckStore, "setPanels"); + + sinon.stub(listStore, "getData"); + + let component = new SyncedTabsDeckComponent({ + window: mockWindow, + deckStore, + listStore, + listComponent, + SyncedTabs, + DeckView: ViewMock, + }); + + sinon.spy(component, "observe"); + sinon.stub(component, "updatePanel"); + sinon.stub(component, "updateDir"); + + component.init(); + SyncedTabs.syncTabs.restore(); + Assert.ok(component.updatePanel.called, "triggers panel update during init"); + Assert.ok( + component.updateDir.called, + "triggers UI direction update during init" + ); + + Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED); + + Assert.ok( + component.observe.calledWith(null, SyncedTabs.TOPIC_TABS_CHANGED), + "component is notified" + ); + + Assert.ok(listStore.getData.called, "gets list data"); + Assert.ok(component.updatePanel.calledTwice, "triggers panel update"); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + Assert.ok( + component.observe.calledWith(null, UIState.ON_UPDATE), + "component is notified of FxA/Sync UI Update" + ); + Assert.equal( + component.updatePanel.callCount, + 3, + "triggers panel update again" + ); + + Services.locale.availableLocales = ["ab-CD"]; + Services.locale.requestedLocales = ["ab-CD"]; + + Assert.ok( + component.updateDir.calledTwice, + "locale change triggers UI direction update" + ); + + Services.prefs.setStringPref("intl.l10n.pseudo", "bidi"); + + Assert.equal( + component.updateDir.callCount, + 3, + "pref change triggers UI direction update" + ); +}); + +add_task(async function testPanelStatus() { + let deckStore = new SyncedTabsDeckStore(); + let listStore = new SyncedTabsListStore(); + let listComponent = {}; + let SyncedTabsMock = { + getTabClients() {}, + }; + + sinon.stub(listStore, "getData"); + + let component = new SyncedTabsDeckComponent({ + deckStore, + listComponent, + SyncedTabs: SyncedTabsMock, + }); + + sinon.stub(UIState, "get").returns({ status: UIState.STATUS_NOT_CONFIGURED }); + let result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.NOT_AUTHED_INFO); + UIState.get.restore(); + + sinon.stub(UIState, "get").returns({ status: UIState.STATUS_NOT_VERIFIED }); + result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.UNVERIFIED); + UIState.get.restore(); + + sinon.stub(UIState, "get").returns({ status: UIState.STATUS_LOGIN_FAILED }); + result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.LOGIN_FAILED); + UIState.get.restore(); + + sinon + .stub(UIState, "get") + .returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: false }); + SyncedTabsMock.isConfiguredToSyncTabs = true; + result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.SYNC_DISABLED); + UIState.get.restore(); + + sinon + .stub(UIState, "get") + .returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: true }); + SyncedTabsMock.isConfiguredToSyncTabs = false; + result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_DISABLED); + + SyncedTabsMock.isConfiguredToSyncTabs = true; + + SyncedTabsMock.hasSyncedThisSession = false; + result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_FETCHING); + + SyncedTabsMock.hasSyncedThisSession = true; + + let clients = []; + sinon + .stub(SyncedTabsMock, "getTabClients") + .callsFake(() => Promise.resolve(clients)); + result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.SINGLE_DEVICE_INFO); + + clients = ["mock-client"]; + result = await component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_CONTAINER); + + sinon + .stub(component, "getPanelStatus") + .callsFake(() => Promise.resolve("mock-panelId")); + sinon.spy(deckStore, "selectPanel"); + await component.updatePanel(); + Assert.ok(deckStore.selectPanel.calledWith("mock-panelId")); +}); + +add_task(async function testActions() { + let windowMock = {}; + let chromeWindowMock = { + gSync: { + openPrefs() {}, + openConnectAnotherDevice() {}, + }, + }; + sinon.spy(chromeWindowMock.gSync, "openPrefs"); + sinon.spy(chromeWindowMock.gSync, "openConnectAnotherDevice"); + + let getChromeWindowMock = sinon.stub(); + getChromeWindowMock.returns(chromeWindowMock); + + let component = new SyncedTabsDeckComponent({ + window: windowMock, + getChromeWindowMock, + }); + + component.openConnectDevice(); + Assert.ok(chromeWindowMock.gSync.openConnectAnotherDevice.called); + + component.openSyncPrefs(); + Assert.ok(getChromeWindowMock.calledWith(windowMock)); + Assert.ok(chromeWindowMock.gSync.openPrefs.called); +}); diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js new file mode 100644 index 0000000000..a75ded8c8a --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js @@ -0,0 +1,69 @@ +"use strict"; + +let { SyncedTabsDeckStore } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsDeckStore.sys.mjs" +); + +add_task(async function testSelectUnkownPanel() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + + deckStore.on("change", spy); + deckStore.selectPanel("foo"); + + Assert.ok(!spy.called); +}); + +add_task(async function testSetPanels() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + + deckStore.on("change", spy); + deckStore.setPanels(["panel1", "panel2"]); + + Assert.ok( + spy.calledWith({ + panels: [ + { id: "panel1", selected: false }, + { id: "panel2", selected: false }, + ], + isUpdatable: false, + }) + ); +}); + +add_task(async function testSelectPanel() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + + deckStore.setPanels(["panel1", "panel2"]); + + deckStore.on("change", spy); + deckStore.selectPanel("panel2"); + + Assert.ok( + spy.calledWith({ + panels: [ + { id: "panel1", selected: false }, + { id: "panel2", selected: true }, + ], + isUpdatable: true, + }) + ); + + deckStore.selectPanel("panel2"); + Assert.ok(spy.calledOnce, "doesn't trigger unless panel changes"); +}); + +add_task(async function testSetPanelsSameArray() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + deckStore.on("change", spy); + + let panels = ["panel1", "panel2"]; + + deckStore.setPanels(panels); + deckStore.setPanels(panels); + + Assert.ok(spy.calledOnce, "doesn't trigger unless set of panels changes"); +}); diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js new file mode 100644 index 0000000000..f92192a62b --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js @@ -0,0 +1,289 @@ +"use strict"; + +let { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +let { SyncedTabsListStore } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs" +); + +const FIXTURE = [ + { + id: "2xU5h-4bkWqA", + type: "client", + lastModified: 1492201200, + name: "laptop", + isMobile: false, + tabs: [ + { + type: "tab", + title: + "Firefox for iOS — Mobile Web browser for your iPhone, iPad and iPod touch — Mozilla", + url: "https://www.mozilla.org/en-US/firefox/ios/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar", + icon: "cached-favicon:https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico", + client: "2xU5h-4bkWqA", + lastUsed: 1451519425, + }, + { + type: "tab", + title: "Firefox Nightly First Run Page", + url: "https://www.mozilla.org/en-US/firefox/nightly/firstrun/?oldversion=45.0a1", + icon: "cached-favicon:https://www.mozilla.org/media/img/firefox/favicon-nightly.560395bbb2e1.png", + client: "2xU5h-4bkWqA", + lastUsed: 1451519420, + }, + ], + }, + { + id: "OL3EJCsdb2JD", + type: "client", + lastModified: 1492201200, + name: "desktop", + isMobile: false, + tabs: [], + }, +]; + +add_task(async function testGetDataEmpty() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve([]); + }); + store.on("change", spy); + + await store.getData(); + + Assert.ok(SyncedTabs.getTabClients.calledWith("")); + Assert.ok( + spy.calledWith({ + clients: [], + canUpdateAll: false, + canUpdateInput: false, + filter: "", + inputFocused: false, + }) + ); + + await store.getData("filter"); + + Assert.ok(SyncedTabs.getTabClients.calledWith("filter")); + Assert.ok( + spy.calledWith({ + clients: [], + canUpdateAll: false, + canUpdateInput: true, + filter: "filter", + inputFocused: false, + }) + ); + + SyncedTabs.getTabClients.restore(); +}); + +add_task(async function testRowSelectionWithoutFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(FIXTURE); + }); + + await store.getData(); + SyncedTabs.getTabClients.restore(); + + store.on("change", spy); + + store.selectRow(0, -1); + Assert.ok(spy.args[0][0].canUpdateAll, "can update the whole view"); + Assert.ok(spy.args[0][0].clients[0].selected, "first client is selected"); + + store.moveSelectionUp(); + Assert.ok( + spy.calledOnce, + "can't move up past first client, no change triggered" + ); + + store.selectRow(0, 0); + Assert.ok( + spy.args[1][0].clients[0].tabs[0].selected, + "first tab of first client is selected" + ); + + store.selectRow(0, 0); + Assert.ok(spy.calledTwice, "selecting same row doesn't trigger change"); + + store.selectRow(0, 1); + Assert.ok( + spy.args[2][0].clients[0].tabs[1].selected, + "second tab of first client is selected" + ); + + store.selectRow(1); + Assert.ok(spy.args[3][0].clients[1].selected, "second client is selected"); + + store.moveSelectionDown(); + Assert.equal( + spy.callCount, + 4, + "can't move selection down past last client, no change triggered" + ); + + store.moveSelectionUp(); + Assert.equal(spy.callCount, 5, "changed"); + Assert.ok( + spy.args[4][0].clients[0].tabs[FIXTURE[0].tabs.length - 1].selected, + "move selection up from client selects last tab of previous client" + ); + + store.moveSelectionUp(); + Assert.ok( + spy.args[5][0].clients[0].tabs[FIXTURE[0].tabs.length - 2].selected, + "move selection up from tab selects previous tab of client" + ); +}); + +add_task(async function testToggleBranches() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(FIXTURE); + }); + + await store.getData(); + SyncedTabs.getTabClients.restore(); + + store.selectRow(0); + store.on("change", spy); + + let clientId = FIXTURE[0].id; + store.closeBranch(clientId); + Assert.ok(spy.args[0][0].clients[0].closed, "first client is closed"); + + store.openBranch(clientId); + Assert.ok(!spy.args[1][0].clients[0].closed, "first client is open"); + + store.toggleBranch(clientId); + Assert.ok(spy.args[2][0].clients[0].closed, "first client is toggled closed"); + + store.moveSelectionDown(); + Assert.ok( + spy.args[3][0].clients[1].selected, + "selection skips tabs if client is closed" + ); + + store.moveSelectionUp(); + Assert.ok( + spy.args[4][0].clients[0].selected, + "selection skips tabs if client is closed" + ); +}); + +add_task(async function testRowSelectionWithFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(FIXTURE); + }); + + await store.getData("filter"); + SyncedTabs.getTabClients.restore(); + + store.on("change", spy); + + store.selectRow(0); + Assert.ok( + spy.args[0][0].clients[0].tabs[0].selected, + "first tab is selected" + ); + + store.moveSelectionUp(); + Assert.ok( + spy.calledOnce, + "can't move up past first tab, no change triggered" + ); + + store.moveSelectionDown(); + Assert.ok( + spy.args[1][0].clients[0].tabs[1].selected, + "selection skips tabs if client is closed" + ); + + store.moveSelectionDown(); + Assert.equal( + spy.callCount, + 2, + "can't move selection down past last tab, no change triggered" + ); + + store.selectRow(1); + Assert.equal(spy.callCount, 2, "doesn't trigger change if same row selected"); +}); + +add_task(async function testFilterAndClearFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(FIXTURE); + }); + store.on("change", spy); + + await store.getData("filter"); + + Assert.ok(SyncedTabs.getTabClients.calledWith("filter")); + Assert.ok(!spy.args[0][0].canUpdateAll, "can't update all"); + Assert.ok(spy.args[0][0].canUpdateInput, "can update just input"); + + store.selectRow(0); + + Assert.equal(spy.args[1][0].filter, "filter"); + Assert.ok(spy.args[1][0].clients[0].tabs[0].selected, "tab is selected"); + + await store.clearFilter(); + + Assert.ok(SyncedTabs.getTabClients.calledWith("")); + Assert.ok(!spy.args[2][0].canUpdateAll, "can't update all"); + Assert.ok(!spy.args[2][0].canUpdateInput, "can't just update input"); + + Assert.equal(spy.args[2][0].filter, ""); + Assert.ok( + !spy.args[2][0].clients[0].tabs[0].selected, + "tab is no longer selected" + ); + + SyncedTabs.getTabClients.restore(); +}); + +add_task(async function testFocusBlurInput() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(FIXTURE); + }); + store.on("change", spy); + + await store.getData(); + SyncedTabs.getTabClients.restore(); + + Assert.ok(!spy.args[0][0].canUpdateAll, "must rerender all"); + + store.selectRow(0); + Assert.ok(!spy.args[1][0].inputFocused, "input is not focused"); + Assert.ok(spy.args[1][0].clients[0].selected, "client is selected"); + Assert.ok(spy.args[1][0].clients[0].focused, "client is focused"); + + store.focusInput(); + Assert.ok(spy.args[2][0].inputFocused, "input is focused"); + Assert.ok(spy.args[2][0].clients[0].selected, "client is still selected"); + Assert.ok(!spy.args[2][0].clients[0].focused, "client is no longer focused"); + + store.blurInput(); + Assert.ok(!spy.args[3][0].inputFocused, "input is not focused"); + Assert.ok(spy.args[3][0].clients[0].selected, "client is selected"); + Assert.ok(spy.args[3][0].clients[0].focused, "client is focused"); +}); diff --git a/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js b/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js new file mode 100644 index 0000000000..734baac254 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js @@ -0,0 +1,190 @@ +"use strict"; + +let { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +let { TabListComponent } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/TabListComponent.sys.mjs" +); +let { SyncedTabsListStore } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs" +); + +const ACTION_METHODS = [ + "onSelectRow", + "onOpenTab", + "onOpenTabs", + "onMoveSelectionDown", + "onMoveSelectionUp", + "onToggleBranch", + "onBookmarkTab", + "onSyncRefresh", + "onFilter", + "onClearFilter", + "onFilterFocus", + "onFilterBlur", +]; + +add_task(async function testInitUninit() { + let store = new SyncedTabsListStore(); + let ViewMock = sinon.stub(); + let view = { render() {}, destroy() {} }; + let mockWindow = {}; + + ViewMock.returns(view); + + sinon.spy(view, "render"); + sinon.spy(view, "destroy"); + + sinon.spy(store, "on"); + sinon.stub(store, "getData"); + sinon.stub(store, "focusInput"); + + let component = new TabListComponent({ + window: mockWindow, + store, + View: ViewMock, + SyncedTabs, + }); + + for (let action of ACTION_METHODS) { + sinon.stub(component, action); + } + + component.init(); + + Assert.ok(ViewMock.calledWithNew(), "view is instantiated"); + Assert.ok(store.on.calledOnce, "listener is added to store"); + Assert.equal(store.on.args[0][0], "change"); + Assert.ok( + view.render.calledWith({ clients: [] }), + "render is called on view instance" + ); + Assert.ok(store.getData.calledOnce, "store gets initial data"); + Assert.ok(store.focusInput.calledOnce, "input field is focused"); + + for (let method of ACTION_METHODS) { + let action = ViewMock.args[0][1][method]; + Assert.ok(action, method + " action is passed to View"); + action("foo", "bar"); + Assert.ok( + component[method].calledWith("foo", "bar"), + method + " action passed to View triggers the component method with args" + ); + } + + store.emit("change", "mock state"); + Assert.ok( + view.render.secondCall.calledWith("mock state"), + "view.render is called on state change" + ); + + component.uninit(); + Assert.ok(view.destroy.calledOnce, "view is destroyed on uninit"); +}); + +add_task(async function testActions() { + let store = new SyncedTabsListStore(); + let chromeWindowMock = { + gBrowser: { + loadTabs() {}, + }, + }; + let getChromeWindowMock = sinon.stub(); + getChromeWindowMock.returns(chromeWindowMock); + let clipboardHelperMock = { + copyString() {}, + }; + let windowMock = { + top: { + PlacesCommandHook: { + bookmarkLink() { + return Promise.resolve(); + }, + }, + }, + openDialog() {}, + openTrustedLinkIn() {}, + }; + let component = new TabListComponent({ + window: windowMock, + store, + View: null, + SyncedTabs, + clipboardHelper: clipboardHelperMock, + getChromeWindow: getChromeWindowMock, + }); + + sinon.stub(store, "getData"); + component.onFilter("query"); + Assert.ok(store.getData.calledWith("query")); + + sinon.stub(store, "clearFilter"); + component.onClearFilter(); + Assert.ok(store.clearFilter.called); + + sinon.stub(store, "focusInput"); + component.onFilterFocus(); + Assert.ok(store.focusInput.called); + + sinon.stub(store, "blurInput"); + component.onFilterBlur(); + Assert.ok(store.blurInput.called); + + sinon.stub(store, "selectRow"); + component.onSelectRow([-1, -1]); + Assert.ok(store.selectRow.calledWith(-1, -1)); + + sinon.stub(store, "moveSelectionDown"); + component.onMoveSelectionDown(); + Assert.ok(store.moveSelectionDown.called); + + sinon.stub(store, "moveSelectionUp"); + component.onMoveSelectionUp(); + Assert.ok(store.moveSelectionUp.called); + + sinon.stub(store, "toggleBranch"); + component.onToggleBranch("foo-id"); + Assert.ok(store.toggleBranch.calledWith("foo-id")); + + sinon.spy(windowMock.top.PlacesCommandHook, "bookmarkLink"); + component.onBookmarkTab("uri", "title"); + Assert.equal(windowMock.top.PlacesCommandHook.bookmarkLink.args[0][0], "uri"); + Assert.equal( + windowMock.top.PlacesCommandHook.bookmarkLink.args[0][1], + "title" + ); + + sinon.spy(windowMock, "openTrustedLinkIn"); + component.onOpenTab("uri", "where", "params"); + Assert.ok(windowMock.openTrustedLinkIn.calledWith("uri", "where", "params")); + + sinon.spy(chromeWindowMock.gBrowser, "loadTabs"); + let tabsToOpen = ["uri1", "uri2"]; + component.onOpenTabs(tabsToOpen, "where"); + Assert.ok(getChromeWindowMock.calledWith(windowMock)); + Assert.ok( + chromeWindowMock.gBrowser.loadTabs.calledWith(tabsToOpen, { + inBackground: false, + replace: false, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }) + ); + component.onOpenTabs(tabsToOpen, "tabshifted"); + Assert.ok( + chromeWindowMock.gBrowser.loadTabs.calledWith(tabsToOpen, { + inBackground: true, + replace: false, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }) + ); + + sinon.spy(clipboardHelperMock, "copyString"); + component.onCopyTabLocation("uri"); + Assert.ok(clipboardHelperMock.copyString.calledWith("uri")); + + sinon.stub(SyncedTabs, "syncTabs"); + component.onSyncRefresh(); + Assert.ok(SyncedTabs.syncTabs.calledWith(true)); + SyncedTabs.syncTabs.restore(); +}); diff --git a/browser/components/syncedtabs/test/xpcshell/xpcshell.toml b/browser/components/syncedtabs/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..d343593644 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/xpcshell.toml @@ -0,0 +1,14 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "head.js" +firefox-appdir = "browser" + +["test_EventEmitter.js"] + +["test_SyncedTabsDeckComponent.js"] + +["test_SyncedTabsDeckStore.js"] + +["test_SyncedTabsListStore.js"] + +["test_TabListComponent.js"] diff --git a/browser/components/syncedtabs/util.sys.mjs b/browser/components/syncedtabs/util.sys.mjs new file mode 100644 index 0000000000..7c93733c72 --- /dev/null +++ b/browser/components/syncedtabs/util.sys.mjs @@ -0,0 +1,8 @@ +/* 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/. */ + +// Get the chrome (ie, browser) window hosting this content. +export function getChromeWindow(window) { + return window.browsingContext.topChromeWindow; +} |