Bug 1474440 - Implement support for the 'onHighlighted' API for multiselect tabs draft
authorOriol Brufau <oriol-bugzilla@hotmail.com>
Sat, 28 Jul 2018 13:15:55 +0200
changeset 828634 3e7909af43cf63aeca99f1ff941e37ea1275242c
parent 828633 e0e2ac636b0d563794068e2b09d96048b3aa772a
push id118685
push userbmo:oriol-bugzilla@hotmail.com
push dateSun, 12 Aug 2018 12:21:52 +0000
bugs1474440
milestone63.0a1
Bug 1474440 - Implement support for the 'onHighlighted' API for multiselect tabs MozReview-Commit-ID: 8aOmdj0AB3e
browser/components/extensions/parent/ext-browser.js
browser/components/extensions/parent/ext-tabs.js
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_tabs_highlight.js
browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js
toolkit/components/extensions/ExtensionCommon.jsm
--- a/browser/components/extensions/parent/ext-browser.js
+++ b/browser/components/extensions/parent/ext-browser.js
@@ -280,16 +280,17 @@ class TabTracker extends TabTrackerBase 
     this.adoptedTabs = new WeakMap();
 
     this._handleWindowOpen = this._handleWindowOpen.bind(this);
     this._handleWindowClose = this._handleWindowClose.bind(this);
 
     windowTracker.addListener("TabClose", this);
     windowTracker.addListener("TabOpen", this);
     windowTracker.addListener("TabSelect", this);
+    windowTracker.addListener("TabMultiSelect", this);
     windowTracker.addOpenListener(this._handleWindowOpen);
     windowTracker.addCloseListener(this._handleWindowClose);
 
     Services.mm.addMessageListener("Reader:UpdateReaderButton", this);
 
     /* eslint-disable mozilla/balanced-listeners */
     this.on("tab-detached", this._handleTabDestroyed);
     this.on("tab-removed", this._handleTabDestroyed);
@@ -451,16 +452,26 @@ class TabTracker extends TabTrackerBase 
 
       case "TabSelect":
         // Because we are delaying calling emitCreated above, we also need to
         // delay sending this event because it shouldn't fire before onCreated.
         Promise.resolve().then(() => {
           this.emitActivated(nativeTab);
         });
         break;
+
+      case "TabMultiSelect":
+        if (this.has("tabs-highlighted")) {
+          // Because we are delaying calling emitCreated above, we also need to
+          // delay sending this event because it shouldn't fire before onCreated.
+          Promise.resolve().then(() => {
+            this.emitHighlighted(event.target.ownerGlobal);
+          });
+        }
+        break;
     }
   }
 
   /**
    * @param {Object} message
    *        The message to handle.
    * @private
    */
@@ -509,16 +520,19 @@ class TabTracker extends TabTrackerBase 
       this.on("tab-detached", listener);
     } else {
       for (let nativeTab of window.gBrowser.tabs) {
         this.emitCreated(nativeTab);
       }
 
       // emitActivated to trigger tab.onActivated/tab.onHighlighted for a newly opened window.
       this.emitActivated(window.gBrowser.tabs[0]);
+      if (this.has("tabs-highlighted")) {
+        this.emitHighlighted(window);
+      }
     }
   }
 
   /**
    * A private method which is called whenever a browser window is closed,
    * and dispatches the necessary events for it.
    *
    * @param {DOMWindow} window
@@ -544,16 +558,29 @@ class TabTracker extends TabTrackerBase 
    */
   emitActivated(nativeTab) {
     this.emit("tab-activated", {
       tabId: this.getId(nativeTab),
       windowId: windowTracker.getId(nativeTab.ownerGlobal)});
   }
 
   /**
+   * Emits a "tabs-highlighted" event for the given tab element.
+   *
+   * @param {ChromeWindow} window
+   *        The window in which the active tab or the set of multiselected tabs changed.
+   * @private
+   */
+  emitHighlighted(window) {
+    let tabIds = window.gBrowser.selectedTabs.map(tab => this.getId(tab));
+    let windowId = windowTracker.getId(window);
+    this.emit("tabs-highlighted", {tabIds, windowId});
+  }
+
+  /**
    * Emits a "tab-attached" event for the given tab element.
    *
    * @param {NativeTab} nativeTab
    *        The tab element in the window to which the tab is being attached.
    * @private
    */
   emitAttached(nativeTab) {
     let newWindowId = windowTracker.getId(nativeTab.ownerGlobal);
--- a/browser/components/extensions/parent/ext-tabs.js
+++ b/browser/components/extensions/parent/ext-tabs.js
@@ -406,33 +406,27 @@ this.tabs = class extends ExtensionAPI {
 
             tabTracker.on("tab-created", listener);
             return () => {
               tabTracker.off("tab-created", listener);
             };
           },
         }).api(),
 
-        /**
-         * Since multiple tabs currently can't be highlighted, onHighlighted
-         * essentially acts an alias for self.tabs.onActivated but returns
-         * the tabId in an array to match the API.
-         * @see  https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted
-        */
         onHighlighted: new EventManager({
           context,
           name: "tabs.onHighlighted",
           register: fire => {
             let listener = (eventName, event) => {
-              fire.async({tabIds: [event.tabId], windowId: event.windowId});
+              fire.async(event);
             };
 
-            tabTracker.on("tab-activated", listener);
+            tabTracker.on("tabs-highlighted", listener);
             return () => {
-              tabTracker.off("tab-activated", listener);
+              tabTracker.off("tabs-highlighted", listener);
             };
           },
         }).api(),
 
         onAttached: new EventManager({
           context,
           name: "tabs.onAttached",
           register: fire => {
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -193,16 +193,17 @@ skip-if = (verify && !debug && (os == 'm
 [browser_ext_tabs_insertCSS.js]
 [browser_ext_tabs_lastAccessed.js]
 [browser_ext_tabs_lazy.js]
 [browser_ext_tabs_removeCSS.js]
 [browser_ext_tabs_move_array.js]
 [browser_ext_tabs_move_window.js]
 [browser_ext_tabs_move_window_multiple.js]
 [browser_ext_tabs_move_window_pinned.js]
+[browser_ext_tabs_onHighlighted.js]
 [browser_ext_tabs_onUpdated.js]
 [browser_ext_tabs_onUpdated_filter.js]
 [browser_ext_tabs_opener.js]
 [browser_ext_tabs_printPreview.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_readerMode.js]
 [browser_ext_tabs_reload.js]
 [browser_ext_tabs_reload_bypass_cache.js]
--- a/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js
@@ -21,17 +21,17 @@ add_task(async function test_highlighted
         for (let {index, active, highlighted} of tabs) {
           browser.test.assertEq(index == activeIndex, active, "Check Tab.active: " + index);
           let expected = highlightedIndices.includes(index) || index == activeIndex;
           browser.test.assertEq(expected, highlighted, "Check Tab.highlighted: " + index);
         }
         let highlightedTabs = await browser.tabs.query({currentWindow: true, highlighted: true});
         browser.test.assertEq(
           highlightedIndices.concat(activeIndex).sort((a, b) => a - b).join(),
-          highlightedTabs.map(tab => tab.index).sort((a, b) => a - b).join(),
+          highlightedTabs.map(tab => tab.index).join(),
           "Check tabs.query with highlighted:true provides the expected tabs");
       }
 
       browser.test.log("Check that last tab is active, and no other is highlighted");
       await testHighlighted(2, []);
 
       browser.test.log("Highlight first and second tabs");
       await browser.tabs.highlight({tabs: [0, 1]});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js
@@ -0,0 +1,122 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+add_task(async function test_onHighlighted() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["browser.tabs.multiselect", true],
+    ],
+  });
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    background: async function() {
+      async function expectHighlighted(fn, action) {
+        let resolve;
+        let promise = new Promise((r) => {
+          resolve = r;
+        });
+        let expected;
+        let events = [];
+        let listener = (highlightInfo) => {
+          events.push(highlightInfo);
+          if (expected && expected.length >= events.length) {
+            resolve();
+          }
+        };
+        browser.tabs.onHighlighted.addListener(listener);
+        expected = await fn() || [];
+        if (events.length < expected.length) {
+          await promise;
+        }
+        let unexpected = events.splice(expected.length);
+        browser.test.assertEq(
+          JSON.stringify(expected), JSON.stringify(events),
+          `Should get ${expected.length} expected onHighlighted events when ${action}`);
+        if (unexpected.length) {
+          browser.test.fail(
+            `${unexpected.length} unexpected onHighlighted events when ${action}: ` +
+            JSON.stringify(unexpected));
+        }
+        browser.tabs.onHighlighted.removeListener(listener);
+      }
+
+      let [{id, windowId}] = await browser.tabs.query({active: true, currentWindow: true});
+      let windows = [windowId];
+      let tabs = [id];
+
+      await expectHighlighted(async () => {
+        let tab = await browser.tabs.create({active: true, url: "about:blank?1"});
+        tabs.push(tab.id);
+        return [{tabIds: [tabs[1]], windowId: windows[0]}];
+      }, "creating a new active tab");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.update(tabs[0], {active: true});
+        return [{tabIds: [tabs[0]], windowId: windows[0]}];
+      }, "selecting former tab");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.highlight({tabs: [0, 1]});
+        return [{tabIds: [tabs[0], tabs[1]], windowId: windows[0]}];
+      }, "highlighting both tabs");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.highlight({tabs: [1, 0]});
+        return [{tabIds: [tabs[0], tabs[1]], windowId: windows[0]}];
+      }, "highlighting same tabs but changing selected one");
+
+      await expectHighlighted(async () => {
+        let tab = await browser.tabs.create({active: false, url: "about:blank?2"});
+        tabs.push(tab.id);
+      }, "create a new inactive tab");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.highlight({tabs: [2, 0, 1]});
+        return [{tabIds: [tabs[0], tabs[1], tabs[2]], windowId: windows[0]}];
+      }, "highlighting all tabs");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.move(tabs[1], {index: 0});
+      }, "reordering tabs");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.highlight({tabs: [0]});
+        return [{tabIds: [tabs[1]], windowId: windows[0]}];
+      }, "highlighting moved tab");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.highlight({tabs: [0]});
+      }, "highlighting again");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.highlight({tabs: [2, 1, 0]});
+        return [{tabIds: [tabs[1], tabs[0], tabs[2]], windowId: windows[0]}];
+      }, "highlighting all tabs");
+
+      await expectHighlighted(async () => {
+        await browser.tabs.highlight({tabs: [2, 0, 1]});
+      }, "highlighting same tabs with different order");
+
+      await expectHighlighted(async () => {
+        let window = await browser.windows.create({tabId: tabs[2]});
+        windows.push(window.id);
+        // Bug 1481185: on Chrome it's [tabs[1], tabs[0]] instead of [tabs[0]]
+        return [{tabIds: [tabs[0]], windowId: windows[0]},
+                {tabIds: [tabs[2]], windowId: windows[1]}];
+      }, "moving selected tab into a new window");
+
+      await browser.tabs.remove(tabs.slice(1));
+      browser.test.notifyPass("test-finished");
+    },
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("test-finished");
+  await extension.unload();
+});
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -210,16 +210,27 @@ const ONCE_MAP = Symbol("onceMap");
 
 class EventEmitter {
   constructor() {
     this[LISTENERS] = new Map();
     this[ONCE_MAP] = new WeakMap();
   }
 
   /**
+   * Checks whether there is some listener for the given event.
+   *
+   * @param {string} event
+   *       The name of the event to listen for.
+   * @returns {boolean}
+   */
+  has(event) {
+    return this[LISTENERS].has(event);
+  }
+
+  /**
    * Adds the given function as a listener for the given event.
    *
    * The listener function may optionally return a Promise which
    * resolves when it has completed all operations which event
    * dispatchers may need to block on.
    *
    * @param {string} event
    *       The name of the event to listen for.