diff options
Diffstat (limited to 'browser/components/syncedtabs/test')
12 files changed, 1224 insertions, 0 deletions
diff --git a/browser/components/syncedtabs/test/browser/.eslintrc.js b/browser/components/syncedtabs/test/browser/.eslintrc.js new file mode 100644 index 000000000..7c8021192 --- /dev/null +++ b/browser/components/syncedtabs/test/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/mochitest/browser.eslintrc.js" + ] +}; diff --git a/browser/components/syncedtabs/test/browser/browser.ini b/browser/components/syncedtabs/test/browser/browser.ini new file mode 100644 index 000000000..02fa364f1 --- /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 000000000..afbc00282 --- /dev/null +++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js @@ -0,0 +1,410 @@ +"use strict"; + +const FIXTURE = [ + { + "id": "7cqCr77ptzX3", + "type": "client", + "name": "zcarter's Nightly on MacBook-Pro-25", + "isMobile": false, + "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://mozapps/skin/places/defaultFavicon.png", + "client": "7cqCr77ptzX3", + "lastUsed": 1452124677 + } + ] + }, + { + "id": "2xU5h-4bkWqA", + "type": "client", + "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 + }, + { + // 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", + "name": "desktop", + "isMobile": false, + "tabs": [] + } +]; + +let originalSyncedTabsInternal = null; + +function* testClean() { + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + syncedTabsDeckComponent._accountStatus.restore(); + SyncedTabs._internal.getTabClients.restore(); + SyncedTabs._internal = originalSyncedTabsInternal; + + yield new Promise(resolve => { + window.SidebarUI.browser.contentWindow.addEventListener("unload", function listener() { + window.SidebarUI.browser.contentWindow.removeEventListener("unload", listener); + resolve(); + }); + SidebarUI.hide(); + }); +} + +add_task(function* testSyncedTabsSidebarList() { + yield SidebarUI.show('viewTabsSidebar'); + + Assert.equal(SidebarUI.currentID, "viewTabsSidebar", "Sidebar should have SyncedTabs loaded"); + + let syncedTabsDeckComponent = SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = SidebarUI.browser.contentWindow.SyncedTabs; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { return Promise.resolve([]) }, + syncTabs() { return Promise.resolve(); }, + }; + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(true)); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve(Cu.cloneInto(FIXTURE, {}))); + + yield 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 + yield 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(function* testSyncedTabsSidebarFilteredList() { + yield SidebarUI.show('viewTabsSidebar'); + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { return Promise.resolve([]) }, + syncTabs() { return Promise.resolve(); }, + }; + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(true)); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve(Cu.cloneInto(FIXTURE, {}))); + + yield 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 + yield syncedTabsDeckComponent.tabListComponent._store.getData(); + + let filterInput = syncedTabsDeckComponent._window.document.querySelector(".tabsFilter"); + filterInput.value = "filter text"; + filterInput.blur(); + + yield 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); + yield syncedTabsDeckComponent.tabListComponent._store.getData(); + Array.prototype.forEach.call(selectedPanel.querySelectorAll(".tab"), (tabNode, i) => { + checkItem(tabNode, FIXTURE_TABS[i]); + }); +}); + +add_task(testClean); + +add_task(function* testSyncedTabsSidebarStatus() { + let accountExists = false; + + yield SidebarUI.show('viewTabsSidebar'); + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: false, + hasSyncedThisSession: false, + getTabClients() {}, + syncTabs() { return Promise.resolve(); }, + }; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + sinon.spy(syncedTabsDeckComponent, "updatePanel"); + sinon.spy(syncedTabsDeckComponent, "observe"); + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.reject("Test error")); + yield syncedTabsDeckComponent.updatePanel(); + + let selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("notAuthedInfo"), + "not-authed panel is selected on auth error"); + + syncedTabsDeckComponent._accountStatus.restore(); + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(accountExists)); + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("notAuthedInfo"), + "not-authed panel is selected"); + + accountExists = true; + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("tabs-disabled"), + "tabs disabled panel is selected"); + + SyncedTabs._internal.isConfiguredToSyncTabs = true; + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("tabs-fetching"), + "tabs fetch panel is selected"); + + SyncedTabs._internal.hasSyncedThisSession = true; + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve([])); + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("singleDeviceInfo"), + "tabs fetch panel is selected"); + + SyncedTabs._internal.getTabClients.restore(); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve([{id: "mock"}])); + yield 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(function* testSyncedTabsSidebarContextMenu() { + yield SidebarUI.show('viewTabsSidebar'); + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { return Promise.resolve([]) }, + syncTabs() { return Promise.resolve(); }, + }; + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(true)); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve(Cu.cloneInto(FIXTURE, {}))); + + yield 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 + yield 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", + ]; + yield* testContextMenu(syncedTabsDeckComponent, + "#SyncedTabsSidebarTabsFilterContext", + ".tabsFilter", + filterMenuItems); + + info("Right-clicking a tab should show additional actions"); + let tabMenuItems = [ + ["menuitem#syncedTabsOpenSelected", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInTab", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: false }], + ["menuseparator", { hidden: false }], + ["menuitem#syncedTabsBookmarkSelected", { hidden: false }], + ["menuitem#syncedTabsCopySelected", { hidden: false }], + ["menuseparator", { hidden: false }], + ["menuitem#syncedTabsRefresh", { hidden: false }], + ]; + yield* testContextMenu(syncedTabsDeckComponent, + "#SyncedTabsSidebarContext", + "#tab-7cqCr77ptzX3-0", + tabMenuItems); + + info("Right-clicking a client shouldn't show any actions"); + let sidebarMenuItems = [ + ["menuitem#syncedTabsOpenSelected", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInTab", { 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#syncedTabsRefresh", { hidden: false }], + ]; + yield* testContextMenu(syncedTabsDeckComponent, + "#SyncedTabsSidebarContext", + "#item-OL3EJCsdb2JD", + sidebarMenuItems); +}); + +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"); + } +} + +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 `sidebarheader`. + let offsetX = contentRect.x + rect.x + (rect.width / 2); + let offsetY = contentRect.y + rect.y + (rect.height / 4); + + yield EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, { + type: "contextmenu", + button: 2, + }, chromeWindow); + yield promisePopupShown; + is(triggerElement.classList.contains("closed"), isClosed, + "Showing the context menu shouldn't toggle the tab list"); + checkChildren(contextMenu, menuSelectors); + + let promisePopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.hidePopup(); + yield promisePopupHidden; +} + +function checkChildren(node, selectors) { + is(node.children.length, selectors.length, "Menu item count doesn't match"); + 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`); + }); + } + } +} diff --git a/browser/components/syncedtabs/test/browser/head.js b/browser/components/syncedtabs/test/browser/head.js new file mode 100644 index 000000000..40e36123e --- /dev/null +++ b/browser/components/syncedtabs/test/browser/head.js @@ -0,0 +1,19 @@ +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); + + +// Load mocking/stubbing library, sinon +// docs: http://sinonjs.org/docs/ +/* global sinon */ +let loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader); +loader.loadSubScript("resource://testing-common/sinon-1.16.1.js"); + +registerCleanupFunction(function*() { + // Cleanup window or the test runner will throw an error + delete window.sinon; + delete window.setImmediate; + delete window.clearImmediate; +}); diff --git a/browser/components/syncedtabs/test/xpcshell/.eslintrc.js b/browser/components/syncedtabs/test/xpcshell/.eslintrc.js new file mode 100644 index 000000000..d35787cd2 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/browser/components/syncedtabs/test/xpcshell/head.js b/browser/components/syncedtabs/test/xpcshell/head.js new file mode 100644 index 000000000..00055231c --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/head.js @@ -0,0 +1,29 @@ +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () { + return Components.utils.import("resource://gre/modules/FxAccountsCommon.js", {}); +}); + +Cu.import("resource://gre/modules/Timer.jsm"); + +do_get_profile(); // fxa needs a profile directory for storage. + +// Create a window polyfill so sinon can load +let window = { + document: {}, + location: {}, + setTimeout: setTimeout, + setInterval: setInterval, + clearTimeout: clearTimeout, + clearinterval: clearInterval +}; +let self = window; + +// Load mocking/stubbing library, sinon +// docs: http://sinonjs.org/docs/ +/* global sinon */ +let loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader); +loader.loadSubScript("resource://testing-common/sinon-1.16.1.js"); 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 000000000..bc73ac621 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js @@ -0,0 +1,35 @@ +"use strict"; + +let { EventEmitter } = Cu.import("resource:///modules/syncedtabs/EventEmitter.jsm", {}); + +add_task(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(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 000000000..3d748b33c --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js @@ -0,0 +1,218 @@ +"use strict"; + +let { SyncedTabs } = Cu.import("resource://services-sync/SyncedTabs.jsm", {}); +let { SyncedTabsDeckComponent } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckComponent.js", {}); +let { TabListComponent } = Cu.import("resource:///modules/syncedtabs/TabListComponent.js", {}); +let { SyncedTabsListStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js", {}); +let { SyncedTabsDeckStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckStore.js", {}); +let { TabListView } = Cu.import("resource:///modules/syncedtabs/TabListView.js", {}); +let { DeckView } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckView.js", {}); + + +add_task(function* testInitUninit() { + let deckStore = new SyncedTabsDeckStore(); + let listComponent = {}; + + let ViewMock = sinon.stub(); + let view = {render: sinon.spy(), destroy: sinon.spy(), container: {}}; + ViewMock.returns(view); + + sinon.stub(SyncedTabs, "syncTabs", () => Promise.resolve()); + + sinon.spy(deckStore, "on"); + sinon.stub(deckStore, "setPanels"); + + let component = new SyncedTabsDeckComponent({ + window, + 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], window); + Assert.equal(ViewMock.args[0][1], listComponent); + Assert.ok(ViewMock.args[0][2].onAndroidClick, + "view is passed onAndroidClick prop"); + Assert.ok(ViewMock.args[0][2].oniOSClick, + "view is passed oniOSClick 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"); +}); + + +function waitForObserver() { + return new Promise((resolve, reject) => { + Services.obs.addObserver((subject, topic) => { + resolve(); + }, SyncedTabs.TOPIC_TABS_CHANGED, false); + }); +} + +add_task(function* testObserver() { + let deckStore = new SyncedTabsDeckStore(); + let listStore = new SyncedTabsListStore(SyncedTabs); + let listComponent = {}; + + let ViewMock = sinon.stub(); + let view = {render: sinon.spy(), destroy: sinon.spy(), container: {}}; + ViewMock.returns(view); + + sinon.stub(SyncedTabs, "syncTabs", () => Promise.resolve()); + + sinon.spy(deckStore, "on"); + sinon.stub(deckStore, "setPanels"); + + sinon.stub(listStore, "getData"); + + let component = new SyncedTabsDeckComponent({ + window, + deckStore, + listStore, + listComponent, + SyncedTabs, + DeckView: ViewMock, + }); + + sinon.spy(component, "observe"); + sinon.stub(component, "updatePanel"); + + component.init(); + SyncedTabs.syncTabs.restore(); + Assert.ok(component.updatePanel.called, "triggers panel 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, FxAccountsCommon.ONLOGIN_NOTIFICATION, ""); + + Assert.ok(component.observe.calledWith(null, FxAccountsCommon.ONLOGIN_NOTIFICATION, ""), + "component is notified of login"); + Assert.equal(component.updatePanel.callCount, 3, "triggers panel update again"); +}); + +add_task(function* testPanelStatus() { + let deckStore = new SyncedTabsDeckStore(); + let listStore = new SyncedTabsListStore(); + let listComponent = {}; + let fxAccounts = { + accountStatus() {} + }; + let SyncedTabsMock = { + getTabClients() {} + }; + + sinon.stub(listStore, "getData"); + + + let component = new SyncedTabsDeckComponent({ + fxAccounts, + deckStore, + listComponent, + SyncedTabs: SyncedTabsMock, + }); + + let isAuthed = false; + sinon.stub(fxAccounts, "accountStatus", () => Promise.resolve(isAuthed)); + let result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.NOT_AUTHED_INFO); + + isAuthed = true; + + SyncedTabsMock.isConfiguredToSyncTabs = false; + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_DISABLED); + + SyncedTabsMock.isConfiguredToSyncTabs = true; + + SyncedTabsMock.hasSyncedThisSession = false; + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_FETCHING); + + SyncedTabsMock.hasSyncedThisSession = true; + + let clients = []; + sinon.stub(SyncedTabsMock, "getTabClients", () => Promise.resolve(clients)); + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.SINGLE_DEVICE_INFO); + + clients = ["mock-client"]; + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_CONTAINER); + + fxAccounts.accountStatus.restore(); + sinon.stub(fxAccounts, "accountStatus", () => Promise.reject("err")); + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.NOT_AUTHED_INFO); + + sinon.stub(component, "getPanelStatus", () => Promise.resolve("mock-panelId")); + sinon.spy(deckStore, "selectPanel"); + yield component.updatePanel(); + Assert.ok(deckStore.selectPanel.calledWith("mock-panelId")); +}); + +add_task(function* testActions() { + let windowMock = { + openUILink() {}, + }; + let chromeWindowMock = { + gSyncUI: { + openSetup() {} + } + }; + sinon.spy(windowMock, "openUILink"); + sinon.spy(chromeWindowMock.gSyncUI, "openSetup"); + + let getChromeWindowMock = sinon.stub(); + getChromeWindowMock.returns(chromeWindowMock); + + let component = new SyncedTabsDeckComponent({ + window: windowMock, + getChromeWindowMock + }); + + let href = Services.prefs.getCharPref("identity.mobilepromo.android") + "synced-tabs-sidebar"; + component.openAndroidLink("mock-event"); + Assert.ok(windowMock.openUILink.calledWith(href, "mock-event")); + + href = Services.prefs.getCharPref("identity.mobilepromo.ios") + "synced-tabs-sidebar"; + component.openiOSLink("mock-event"); + Assert.ok(windowMock.openUILink.calledWith(href, "mock-event")); + + component.openSyncPrefs(); + Assert.ok(getChromeWindowMock.calledWith(windowMock)); + Assert.ok(chromeWindowMock.gSyncUI.openSetup.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 000000000..69abb4024 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js @@ -0,0 +1,64 @@ +"use strict"; + +let { SyncedTabsDeckStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckStore.js", {}); + +add_task(function* testSelectUnkownPanel() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + + deckStore.on("change", spy); + deckStore.selectPanel("foo"); + + Assert.ok(!spy.called); +}); + +add_task(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(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(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 000000000..51580235f --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js @@ -0,0 +1,266 @@ +"use strict"; + +let { SyncedTabs } = Cu.import("resource://services-sync/SyncedTabs.jsm", {}); +let { SyncedTabsListStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js", {}); + +const FIXTURE = [ + { + "id": "2xU5h-4bkWqA", + "type": "client", + "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", + "name": "desktop", + "isMobile": false, + "tabs": [] + } +]; + +add_task(function* testGetDataEmpty() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve([]); + }); + store.on("change", spy); + + yield store.getData(); + + Assert.ok(SyncedTabs.getTabClients.calledWith("")); + Assert.ok(spy.calledWith({ + clients: [], + canUpdateAll: false, + canUpdateInput: false, + filter: "", + inputFocused: false + })); + + yield 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(function* testRowSelectionWithoutFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + + yield 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(function* testToggleBranches() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + + yield 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(function* testRowSelectionWithFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + + yield 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(function* testFilterAndClearFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + store.on("change", spy); + + yield 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"); + + yield 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(function* testFocusBlurInput() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + store.on("change", spy); + + yield 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 000000000..0b0665a1b --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js @@ -0,0 +1,155 @@ +"use strict"; + +let { SyncedTabs } = Cu.import("resource://services-sync/SyncedTabs.jsm", {}); +let { TabListComponent } = Cu.import("resource:///modules/syncedtabs/TabListComponent.js", {}); +let { SyncedTabsListStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js", {}); +let { View } = Cu.import("resource:///modules/syncedtabs/TabListView.js", {}); + +const ACTION_METHODS = [ + "onSelectRow", + "onOpenTab", + "onOpenTabs", + "onMoveSelectionDown", + "onMoveSelectionUp", + "onToggleBranch", + "onBookmarkTab", + "onSyncRefresh", + "onFilter", + "onClearFilter", + "onFilterFocus", + "onFilterBlur", +]; + +add_task(function* testInitUninit() { + let store = new SyncedTabsListStore(); + let ViewMock = sinon.stub(); + let view = {render() {}, destroy() {}}; + + 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, 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(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(); } + }, + PlacesUtils: { bookmarksMenuFolderId: "id" } + }, + getBrowserURL() {}, + openDialog() {}, + openUILinkIn() {} + }; + 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][1], "uri"); + Assert.equal(windowMock.top.PlacesCommandHook.bookmarkLink.args[0][2], "title"); + + sinon.spy(windowMock, "openUILinkIn"); + component.onOpenTab("uri", "where", "params"); + Assert.ok(windowMock.openUILinkIn.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, false, false)); + component.onOpenTabs(tabsToOpen, "tabshifted"); + Assert.ok(chromeWindowMock.gBrowser.loadTabs.calledWith(tabsToOpen, true, false)); + + 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 000000000..1cb8dcb7a --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini @@ -0,0 +1,10 @@ +[DEFAULT] +head = head.js +tail = +firefox-appdir = browser + +[test_EventEmitter.js] +[test_SyncedTabsDeckStore.js] +[test_SyncedTabsListStore.js] +[test_SyncedTabsDeckComponent.js] +[test_TabListComponent.js] |