browser_synced_tabs_menu.js (18420B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 requestLongerTimeout(2); 8 9 const { FxAccounts } = ChromeUtils.importESModule( 10 "resource://gre/modules/FxAccounts.sys.mjs" 11 ); 12 let { SyncedTabs } = ChromeUtils.importESModule( 13 "resource://services-sync/SyncedTabs.sys.mjs" 14 ); 15 let { UIState } = ChromeUtils.importESModule( 16 "resource://services-sync/UIState.sys.mjs" 17 ); 18 19 ChromeUtils.defineESModuleGetters(this, { 20 UITour: "moz-src:///browser/components/uitour/UITour.sys.mjs", 21 }); 22 23 const DECKINDEX_TABS = 0; 24 const DECKINDEX_FETCHING = 1; 25 const DECKINDEX_TABSDISABLED = 2; 26 const DECKINDEX_NOCLIENTS = 3; 27 28 const SAMPLE_TAB_URL = "https://example.com/"; 29 30 var initialLocation = gBrowser.currentURI.spec; 31 var newTab = null; 32 33 // A helper to notify there are new tabs. Returns a promise that is resolved 34 // once the UI has been updated. 35 function updateTabsPanel() { 36 let promiseTabsUpdated = promiseObserverNotified( 37 "synced-tabs-menu:test:tabs-updated" 38 ); 39 Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED); 40 return promiseTabsUpdated; 41 } 42 43 // This is the mock we use for SyncedTabs.sys.mjs - tests may override various 44 // functions. 45 let mockedInternal = { 46 get isConfiguredToSyncTabs() { 47 return true; 48 }, 49 getTabClients() { 50 return Promise.resolve([]); 51 }, 52 syncTabs() { 53 return Promise.resolve(); 54 }, 55 hasSyncedThisSession: false, 56 }; 57 58 add_setup(async function () { 59 const getSignedInUser = FxAccounts.config.getSignedInUser; 60 61 FxAccounts.config.getSignedInUser = async () => 62 Promise.resolve({ uid: "uid", email: "foo@bar.com" }); 63 64 Services.prefs.setCharPref( 65 "identity.fxaccounts.remote.root", 66 "https://example.com/" 67 ); 68 69 // Mock the global fxAccounts object used by gSync 70 const origWindowFxAccounts = window.fxAccounts; 71 window.fxAccounts = { 72 getSignedInUser: async () => ({ uid: "uid", email: "foo@bar.com" }), 73 hasLocalSession: async () => true, 74 keys: { 75 canGetKeyForScope: async () => true, 76 }, 77 device: { 78 recentDeviceList: null, 79 }, 80 }; 81 82 let oldInternal = SyncedTabs._internal; 83 SyncedTabs._internal = mockedInternal; 84 85 let origNotifyStateUpdated = UIState._internal.notifyStateUpdated; 86 // Sync start-up will interfere with our tests, don't let UIState send UI updates. 87 UIState._internal.notifyStateUpdated = () => {}; 88 89 // Force gSync initialization 90 gSync.init(); 91 92 registerCleanupFunction(() => { 93 FxAccounts.config.getSignedInUser = getSignedInUser; 94 Services.prefs.clearUserPref("identity.fxaccounts.remote.root"); 95 UIState._internal.notifyStateUpdated = origNotifyStateUpdated; 96 window.fxAccounts = origWindowFxAccounts; 97 SyncedTabs._internal = oldInternal; 98 }); 99 }); 100 101 // The test expects the about:preferences#sync page to open in the current tab 102 async function openPrefsFromMenuPanel(expectedPanelId, entryPoint) { 103 info("Check Sync button functionality"); 104 CustomizableUI.addWidgetToArea( 105 "sync-button", 106 CustomizableUI.AREA_FIXED_OVERFLOW_PANEL 107 ); 108 109 await waitForOverflowButtonShown(); 110 111 // check the button's functionality 112 await document.getElementById("nav-bar").overflowable.show(); 113 114 if (entryPoint == "uitour") { 115 UITour.tourBrowsersByWindow.set(window, new Set()); 116 UITour.tourBrowsersByWindow.get(window).add(gBrowser.selectedBrowser); 117 } 118 119 let syncButton = document.getElementById("sync-button"); 120 ok(syncButton, "The Sync button was added to the Panel Menu"); 121 122 let tabsUpdatedPromise = promiseObserverNotified( 123 "synced-tabs-menu:test:tabs-updated" 124 ); 125 syncButton.click(); 126 let syncPanel = document.getElementById("PanelUI-remotetabs"); 127 let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); 128 await Promise.all([tabsUpdatedPromise, viewShownPromise]); 129 ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); 130 131 // Sync is not configured - verify that state is reflected. 132 let subpanel = document.getElementById(expectedPanelId); 133 ok(!subpanel.hidden, "sync setup element is visible"); 134 135 // Find and click the "setup" button. 136 let setupButton = subpanel.querySelector(".PanelUI-remotetabs-button"); 137 setupButton.click(); 138 139 await new Promise(resolve => { 140 let handler = async e => { 141 if ( 142 e.originalTarget != gBrowser.selectedBrowser.contentDocument || 143 e.target.location.href == "about:blank" 144 ) { 145 info("Skipping spurious 'load' event for " + e.target.location.href); 146 return; 147 } 148 gBrowser.selectedBrowser.removeEventListener("load", handler, true); 149 resolve(); 150 }; 151 gBrowser.selectedBrowser.addEventListener("load", handler, true); 152 }); 153 newTab = gBrowser.selectedTab; 154 155 is( 156 gBrowser.currentURI.spec, 157 "about:preferences?entrypoint=" + entryPoint + "#sync", 158 "Firefox Sync preference page opened with `menupanel` entrypoint" 159 ); 160 ok(!isOverflowOpen(), "The panel closed"); 161 162 if (isOverflowOpen()) { 163 await hideOverflow(); 164 } 165 } 166 167 function hideOverflow() { 168 let panelHidePromise = promiseOverflowHidden(window); 169 PanelUI.overflowPanel.hidePopup(); 170 return panelHidePromise; 171 } 172 173 async function asyncCleanup() { 174 // reset the panel UI to the default state 175 await resetCustomization(); 176 ok(CustomizableUI.inDefaultState, "The panel UI is in default state again."); 177 178 // restore the tabs 179 BrowserTestUtils.addTab(gBrowser, initialLocation); 180 gBrowser.removeTab(newTab); 181 UITour.tourBrowsersByWindow.delete(window); 182 } 183 184 // When Sync is not setup. 185 add_task(async function () { 186 gSync.updateAllUI({ status: UIState.STATUS_NOT_CONFIGURED }); 187 await openPrefsFromMenuPanel("PanelUI-remotetabs-setupsync", "synced-tabs"); 188 }); 189 add_task(asyncCleanup); 190 191 // When an account is connected by Sync is not enabled. 192 add_task(async function () { 193 gSync.updateAllUI({ status: UIState.STATUS_SIGNED_IN, syncEnabled: false }); 194 await openPrefsFromMenuPanel( 195 "PanelUI-remotetabs-syncdisabled", 196 "synced-tabs" 197 ); 198 }); 199 add_task(asyncCleanup); 200 201 // When Sync is configured in an unverified state. 202 add_task(async function () { 203 gSync.updateAllUI({ 204 status: UIState.STATUS_NOT_VERIFIED, 205 email: "foo@bar.com", 206 }); 207 await openPrefsFromMenuPanel("PanelUI-remotetabs-unverified", "synced-tabs"); 208 }); 209 add_task(asyncCleanup); 210 211 // When Sync is configured in a "needs reauthentication" state. 212 add_task(async function () { 213 gSync.updateAllUI({ 214 status: UIState.STATUS_LOGIN_FAILED, 215 email: "foo@bar.com", 216 }); 217 await openPrefsFromMenuPanel("PanelUI-remotetabs-reauthsync", "synced-tabs"); 218 }); 219 220 // Test the Connect Another Device button 221 add_task(async function () { 222 gSync.updateAllUI({ 223 status: UIState.STATUS_SIGNED_IN, 224 syncEnabled: true, 225 email: "foo@bar.com", 226 lastSync: new Date(), 227 }); 228 229 let button = document.getElementById( 230 "PanelUI-remotetabs-connect-device-button" 231 ); 232 ok(button, "found the button"); 233 234 await document.getElementById("nav-bar").overflowable.show(); 235 // Actually show the fxa view: 236 let shown = BrowserTestUtils.waitForEvent( 237 document.getElementById("PanelUI-remotetabs"), 238 "ViewShown" 239 ); 240 PanelUI.showSubView( 241 "PanelUI-remotetabs", 242 document.getElementById("sync-button") 243 ); 244 await shown; 245 246 let promiseTabOpened = BrowserTestUtils.waitForNewTab( 247 gBrowser, 248 url => 249 url.startsWith("https://example.com/connect_another_device") && 250 url.includes("entrypoint=synced-tabs") 251 ); 252 button.click(); 253 // the panel should have been closed. 254 ok(!isOverflowOpen(), "click closed the panel"); 255 await promiseTabOpened; 256 257 gBrowser.removeTab(gBrowser.selectedTab); 258 }); 259 260 // Test the "Sync Now" button 261 add_task(async function () { 262 await SpecialPowers.pushPrefEnv({ 263 set: [["browser.tabs.remoteSVGIconDecoding", true]], 264 }); 265 266 gSync.updateAllUI({ 267 status: UIState.STATUS_SIGNED_IN, 268 syncEnabled: true, 269 email: "foo@bar.com", 270 lastSync: new Date(), 271 }); 272 273 await document.getElementById("nav-bar").overflowable.show(); 274 let tabsUpdatedPromise = promiseObserverNotified( 275 "synced-tabs-menu:test:tabs-updated" 276 ); 277 let syncPanel = document.getElementById("PanelUI-remotetabs"); 278 let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); 279 let syncButton = document.getElementById("sync-button"); 280 syncButton.click(); 281 await Promise.all([tabsUpdatedPromise, viewShownPromise]); 282 ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); 283 284 let subpanel = document.getElementById("PanelUI-remotetabs-main"); 285 ok(!subpanel.hidden, "main pane is visible"); 286 let deck = document.getElementById("PanelUI-remotetabs-deck"); 287 288 // The widget is still fetching tabs, as we've neutered everything that 289 // provides them 290 is(deck.selectedIndex, DECKINDEX_FETCHING, "first deck entry is visible"); 291 292 // Tell the widget there are tabs available, but with zero clients. 293 mockedInternal.getTabClients = () => { 294 return Promise.resolve([]); 295 }; 296 mockedInternal.hasSyncedThisSession = true; 297 await updateTabsPanel(); 298 // The UI should be showing the "no clients" pane. 299 is( 300 deck.selectedIndex, 301 DECKINDEX_NOCLIENTS, 302 "no-clients deck entry is visible" 303 ); 304 305 // Tell the widget there are tabs available - we have 3 clients, one with no 306 // tabs. 307 mockedInternal.getTabClients = () => { 308 return Promise.resolve([ 309 { 310 id: "guid_mobile", 311 type: "client", 312 name: "My Phone", 313 lastModified: 1492201200, 314 tabs: [], 315 }, 316 { 317 id: "guid_desktop", 318 type: "client", 319 name: "My Desktop", 320 lastModified: 1492201200, 321 tabs: [ 322 { 323 title: "http://example.com/10", 324 lastUsed: 10, // the most recent 325 }, 326 { 327 title: "http://example.com/1", 328 lastUsed: 1, // the least recent. 329 }, 330 { 331 title: "http://example.com/5", 332 lastUsed: 5, 333 }, 334 ], 335 }, 336 { 337 id: "guid_second_desktop", 338 name: "My Other Desktop", 339 lastModified: 1492201200, 340 tabs: [ 341 { 342 title: "http://example.com/6", 343 icon: "http://example.com/favicon.ico", 344 lastUsed: 6, 345 }, 346 ], 347 }, 348 ]); 349 }; 350 await updateTabsPanel(); 351 352 // The UI should be showing tabs! 353 is(deck.selectedIndex, DECKINDEX_TABS, "no-clients deck entry is visible"); 354 let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); 355 let node = tabList.firstElementChild; 356 // First entry should be the client with the most-recent tab. 357 is(node.nodeName, "vbox"); 358 let currentClient = node; 359 node = node.firstElementChild; 360 is(node.getAttribute("itemtype"), "client", "node is a client entry"); 361 is(node.textContent, "My Desktop", "correct client"); 362 // Next node is an hbox, that contains the tab and potentially 363 // a button for closing the tab remotely 364 node = node.nextElementSibling; 365 is(node.nodeName, "hbox"); 366 // Next entry is the most-recent tab 367 let childNode = node.firstElementChild; 368 is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); 369 is(childNode.getAttribute("label"), "http://example.com/10"); 370 371 // Next entry is the next-most-recent tab 372 node = node.nextElementSibling; 373 is(node.nodeName, "hbox"); 374 childNode = node.firstElementChild; 375 is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); 376 is(childNode.getAttribute("label"), "http://example.com/5"); 377 378 // Next entry is the least-recent tab from the first client. 379 node = node.nextElementSibling; 380 is(node.nodeName, "hbox"); 381 childNode = node.firstElementChild; 382 is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); 383 is(childNode.getAttribute("label"), "http://example.com/1"); 384 node = node.nextElementSibling; 385 is(node, null, "no more siblings"); 386 387 // Next is a toolbarseparator between the clients. 388 node = currentClient.nextElementSibling; 389 is(node.nodeName, "toolbarseparator"); 390 391 // Next is the container for client 2. 392 node = node.nextElementSibling; 393 is(node.nodeName, "vbox"); 394 currentClient = node; 395 396 // Next is the client with 1 tab. 397 node = node.firstElementChild; 398 is(node.getAttribute("itemtype"), "client", "node is a client entry"); 399 is(node.textContent, "My Other Desktop", "correct client"); 400 // Its single tab 401 node = node.nextElementSibling; 402 is(node.nodeName, "hbox"); 403 childNode = node.firstElementChild; 404 is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); 405 is(childNode.getAttribute("label"), "http://example.com/6"); 406 // Check the favicon image. 407 let image = new URL(childNode.getAttribute("image")); 408 is(image.protocol, "moz-remote-image:", "image protocol is correct"); 409 is( 410 image.searchParams.get("url"), 411 "http://example.com/favicon.ico", 412 "image url is correct" 413 ); 414 node = node.nextElementSibling; 415 is(node, null, "no more siblings"); 416 417 // Next is a toolbarseparator between the clients. 418 node = currentClient.nextElementSibling; 419 is(node.nodeName, "toolbarseparator"); 420 421 // Next is the container for client 3. 422 node = node.nextElementSibling; 423 is(node.nodeName, "vbox"); 424 currentClient = node; 425 426 // Next is the client with no tab. 427 node = node.firstElementChild; 428 is(node.getAttribute("itemtype"), "client", "node is a client entry"); 429 is(node.textContent, "My Phone", "correct client"); 430 // There is a single node saying there's no tabs for the client. 431 node = node.nextElementSibling; 432 is(node.nodeName, "label", "node is a label"); 433 is(node.getAttribute("itemtype"), null, "node is neither a tab nor a client"); 434 435 node = node.nextElementSibling; 436 is(node, null, "no more siblings"); 437 is(currentClient.nextElementSibling, null, "no more clients"); 438 439 // Check accessibility. There should be containers for each client, with an 440 // aria attribute that identifies the client name. 441 let clientContainers = [ 442 ...tabList.querySelectorAll("[aria-labelledby]").values(), 443 ]; 444 let labelIds = clientContainers.map(container => 445 container.getAttribute("aria-labelledby") 446 ); 447 let labels = labelIds.map(id => document.getElementById(id).textContent); 448 Assert.deepEqual(labels.sort(), [ 449 "My Desktop", 450 "My Other Desktop", 451 "My Phone", 452 ]); 453 454 let didSync = false; 455 let oldDoSync = gSync.doSync; 456 gSync.doSync = function () { 457 didSync = true; 458 gSync.doSync = oldDoSync; 459 }; 460 461 let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow"); 462 is(syncNowButton.disabled, false); 463 syncNowButton.click(); 464 ok(didSync, "clicking the button called the correct function"); 465 466 await hideOverflow(); 467 468 await SpecialPowers.popPrefEnv(); 469 }); 470 471 // Test the pagination capabilities (Show More/All tabs) 472 add_task(async function () { 473 mockedInternal.getTabClients = () => { 474 return Promise.resolve([ 475 { 476 id: "guid_desktop", 477 type: "client", 478 name: "My Desktop", 479 lastModified: 1492201200, 480 tabs: (function () { 481 let allTabsDesktop = []; 482 // We choose 77 tabs, because TABS_PER_PAGE is 25, which means 483 // on the second to last page we should have 22 items shown 484 // (because we have to show at least NEXT_PAGE_MIN_TABS=5 tabs on the last page) 485 for (let i = 1; i <= 77; i++) { 486 allTabsDesktop.push({ title: "Tab #" + i, url: SAMPLE_TAB_URL }); 487 } 488 return allTabsDesktop; 489 })(), 490 }, 491 ]); 492 }; 493 494 gSync.updateAllUI({ 495 status: UIState.STATUS_SIGNED_IN, 496 syncEnabled: true, 497 lastSync: new Date(), 498 email: "foo@bar.com", 499 }); 500 501 await document.getElementById("nav-bar").overflowable.show(); 502 let tabsUpdatedPromise = promiseObserverNotified( 503 "synced-tabs-menu:test:tabs-updated" 504 ); 505 let syncPanel = document.getElementById("PanelUI-remotetabs"); 506 let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); 507 let syncButton = document.getElementById("sync-button"); 508 syncButton.click(); 509 await Promise.all([tabsUpdatedPromise, viewShownPromise]); 510 511 // Check pre-conditions 512 ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); 513 let subpanel = document.getElementById("PanelUI-remotetabs-main"); 514 ok(!subpanel.hidden, "main pane is visible"); 515 let deck = document.getElementById("PanelUI-remotetabs-deck"); 516 is(deck.selectedIndex, DECKINDEX_TABS, "we should be showing tabs"); 517 518 function checkTabsPage(tabsShownCount, showMoreLabel) { 519 let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); 520 let node = tabList.firstElementChild.firstElementChild; 521 is(node.getAttribute("itemtype"), "client", "node is a client entry"); 522 is(node.textContent, "My Desktop", "correct client"); 523 for (let i = 0; i < tabsShownCount; i++) { 524 node = node.nextElementSibling; 525 is(node.nodeName, "hbox"); 526 let childNode = node.firstElementChild; 527 is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); 528 is( 529 childNode.getAttribute("label"), 530 "Tab #" + (i + 1), 531 "the tab is the correct one" 532 ); 533 is( 534 childNode.getAttribute("targetURI"), 535 SAMPLE_TAB_URL, 536 "url is the correct one" 537 ); 538 } 539 let showMoreButton; 540 if (showMoreLabel) { 541 node = showMoreButton = node.nextElementSibling; 542 is( 543 node.getAttribute("itemtype"), 544 "showmorebutton", 545 "node is a show more button" 546 ); 547 is(node.getAttribute("label"), showMoreLabel); 548 } 549 node = node.nextElementSibling; 550 is(node, null, "no more entries"); 551 552 return showMoreButton; 553 } 554 555 async function checkCanOpenURL() { 556 let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); 557 let node = 558 tabList.firstElementChild.firstElementChild.nextElementSibling 559 .firstElementChild; 560 let promiseTabOpened = BrowserTestUtils.waitForLocationChange( 561 gBrowser, 562 SAMPLE_TAB_URL 563 ); 564 node.click(); 565 await promiseTabOpened; 566 } 567 568 let showMoreButton; 569 function clickShowMoreButton() { 570 let promise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated"); 571 showMoreButton.click(); 572 return promise; 573 } 574 575 showMoreButton = checkTabsPage(25, "Show more tabs"); 576 await clickShowMoreButton(); 577 578 checkTabsPage(77, null); 579 /* calling this will close the overflow menu */ 580 await checkCanOpenURL(); 581 });