summaryrefslogtreecommitdiffstats
path: root/browser/components/syncedtabs/test
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/syncedtabs/test')
-rw-r--r--browser/components/syncedtabs/test/browser/browser.ini4
-rw-r--r--browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js646
-rw-r--r--browser/components/syncedtabs/test/browser/head.js3
-rw-r--r--browser/components/syncedtabs/test/xpcshell/head.js12
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js36
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js263
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js69
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js289
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js190
-rw-r--r--browser/components/syncedtabs/test/xpcshell/xpcshell.ini10
10 files changed, 1522 insertions, 0 deletions
diff --git a/browser/components/syncedtabs/test/browser/browser.ini b/browser/components/syncedtabs/test/browser/browser.ini
new file mode 100644
index 0000000000..02fa364f10
--- /dev/null
+++ b/browser/components/syncedtabs/test/browser/browser.ini
@@ -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..0874a50019
--- /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: "moz-anno: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: "moz-anno: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: "moz-anno: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..c390b71011
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/head.js
@@ -0,0 +1,12 @@
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () {
+ return ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+});
+
+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..36138aace3
--- /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: "moz-anno: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: "moz-anno: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.ini b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..5b18e0757e
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+skip-if = toolkit == 'android' # bug 1730213
+head = head.js
+firefox-appdir = browser
+
+[test_EventEmitter.js]
+[test_SyncedTabsDeckStore.js]
+[test_SyncedTabsListStore.js]
+[test_SyncedTabsDeckComponent.js]
+[test_TabListComponent.js]