Bug 1238314: Part 2 - Implement browser.tabs openerTabId functionality. r=aswan draft
authorKris Maglione <maglione.k@gmail.com>
Fri, 04 Aug 2017 16:13:59 -0700
changeset 641146 52fa4d59801017906bb461b891be1c2657c8bd7d
parent 641145 65673cf4e43f33f4c2eb36e80a2c56c883f82e61
child 724735 2f3e8bd499a31710ee7d5fac57fd38277934be85
push id72450
push usermaglione.k@gmail.com
push dateSat, 05 Aug 2017 23:28:24 +0000
reviewersaswan
bugs1238314
milestone57.0a1
Bug 1238314: Part 2 - Implement browser.tabs openerTabId functionality. r=aswan MozReview-Commit-ID: L4ycNoQDfa
browser/components/extensions/ext-tabs.js
browser/components/extensions/ext-utils.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_tabs_opener.js
toolkit/components/extensions/ExtensionTabs.jsm
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -358,16 +358,24 @@ this.tabs = class extends ExtensionAPI {
             }
 
             // Make sure things like about:blank and data: URIs never inherit,
             // and instead always get a NullPrincipal.
             options.disallowInheritPrincipal = true;
 
             tabListener.initTabReady();
             let currentTab = window.gBrowser.selectedTab;
+
+            if (createProperties.openerTabId !== null) {
+              options.ownerTab = tabTracker.getTab(createProperties.openerTabId);
+              if (options.ownerTab.ownerGlobal !== window) {
+                return Promise.reject({message: "Opener tab must be in the same window as the tab being created"});
+              }
+            }
+
             let nativeTab = window.gBrowser.addTab(url || window.BROWSER_NEW_TAB_URL, options);
 
             let active = true;
             if (createProperties.active !== null) {
               active = createProperties.active;
             }
             if (active) {
               window.gBrowser.selectedTab = nativeTab;
@@ -441,17 +449,23 @@ this.tabs = class extends ExtensionAPI {
           }
           if (updateProperties.pinned !== null) {
             if (updateProperties.pinned) {
               tabbrowser.pinTab(nativeTab);
             } else {
               tabbrowser.unpinTab(nativeTab);
             }
           }
-          // FIXME: highlighted/selected, openerTabId
+          if (updateProperties.openerTabId !== null) {
+            let opener = tabTracker.getTab(updateProperties.openerTabId);
+            if (opener.ownerDocument !== nativeTab.ownerDocument) {
+              return Promise.reject({message: "Opener tab must be in the same window as the tab being updated"});
+            }
+            tabTracker.setOpener(nativeTab, opener);
+          }
 
           return tabManager.convert(nativeTab);
         },
 
         async reload(tabId, reloadProperties) {
           let nativeTab = getTabOrActive(tabId);
 
           let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -211,16 +211,47 @@ class TabTracker extends TabTrackerBase 
     }
     if (default_ !== undefined) {
       return default_;
     }
     throw new ExtensionError(`Invalid tab ID: ${tabId}`);
   }
 
   /**
+   * Returns the ID of the tab which opened this tab, if the opener tab still
+   * exists. This generally corresponds to the `.owner` property of a tab
+   * element but, unlike that property, is not cleared after an unrelated tab is
+   * opened.
+   *
+   * @param {Element} tab The tab for which to find an owner.
+   * @returns {number|null}
+   */
+  getOpenerId(tab) {
+    let opener = tab.openerTab;
+    if (opener && opener.parentNode && opener.ownerDocument == tab.ownerDocument) {
+      return this.getId(opener);
+    }
+    return null;
+  }
+
+  /**
+   * Sets the opener of `tab` to the ID `openerTab`. Both tabs must be in the
+   * same window, or this function will throw a type error.
+   *
+   * @param {Element} tab The tab for which to set the owner.
+   * @param {Element} openerTab The opener of <tab>.
+   */
+  setOpener(tab, openerTab) {
+    if (tab.ownerDocument !== openerTab.ownerDocument) {
+      throw new Error("Tab must be in the same window as its opener");
+    }
+    tab.openerTab = openerTab;
+  }
+
+  /**
    * @param {Event} event
    *        The DOM Event to handle.
    * @private
    */
   handleEvent(event) {
     let nativeTab = event.target;
 
     switch (event.type) {
@@ -491,16 +522,20 @@ class Tab extends TabBase {
     // height.
     return super.frameLoader || {lazyWidth: 0, lazyHeight: 0};
   }
 
   get cookieStoreId() {
     return getCookieStoreIdForTab(this, this.nativeTab);
   }
 
+  get openerTabId() {
+    return tabTracker.getOpenerId(this.nativeTab);
+  }
+
   get height() {
     return this.frameLoader.lazyHeight;
   }
 
   get index() {
     return this.nativeTab._tPos;
   }
 
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -54,17 +54,17 @@
       },
       {
         "id": "Tab",
         "type": "object",
         "properties": {
           "id": {"type": "integer", "minimum": -1, "optional": true, "description": "The ID of the tab. Tab IDs are unique within a browser session. Under some circumstances a Tab may not be assigned an ID, for example when querying foreign tabs using the $(ref:sessions) API, in which case a session ID may be present. Tab ID can also be set to $(ref:tabs.TAB_ID_NONE) for apps and devtools windows."},
           "index": {"type": "integer", "minimum": -1, "description": "The zero-based index of the tab within its window."},
           "windowId": {"type": "integer", "optional": true, "minimum": 0, "description": "The ID of the window the tab is contained within."},
-          "openerTabId": {"unsupported": true, "type": "integer", "minimum": 0, "optional": true, "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists."},
+          "openerTabId": {"type": "integer", "minimum": 0, "optional": true, "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists."},
           "selected": {"type": "boolean", "description": "Whether the tab is selected.", "deprecated": "Please use $(ref:tabs.Tab.highlighted).", "unsupported": true},
           "highlighted": {"type": "boolean", "description": "Whether the tab is highlighted. Works as an alias of active"},
           "active": {"type": "boolean", "description": "Whether the tab is active in its window. (Does not necessarily mean the window is focused.)"},
           "pinned": {"type": "boolean", "description": "Whether the tab is pinned."},
           "lastAccessed": {"type": "integer", "optional": true, "description": "The last time the tab was accessed as the number of milliseconds since epoch."},
           "audible": {"type": "boolean", "optional": true, "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the speaker audio indicator is showing."},
           "mutedInfo": {"$ref": "MutedInfo", "optional": true, "description": "Current tab muted state and the reason for the last state change."},
           "url": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
@@ -480,17 +480,16 @@
                 "description": "Whether the tab should become the selected tab in the window. Defaults to <var>true</var>"
               },
               "pinned": {
                 "type": "boolean",
                 "optional": true,
                 "description": "Whether the tab should be pinned. Defaults to <var>false</var>"
               },
               "openerTabId": {
-                "unsupported": true,
                 "type": "integer",
                 "minimum": 0,
                 "optional": true,
                 "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as the newly created tab."
               },
               "cookieStoreId": {
                 "type": "string",
                 "optional": true,
@@ -619,16 +618,22 @@
                 "optional": true,
                 "minimum": 0,
                 "description": "The position of the tabs within their windows."
               },
               "cookieStoreId": {
                 "type": "string",
                 "optional": true,
                 "description": "The CookieStoreId used for the tab."
+              },
+              "openerTabId": {
+                "type": "integer",
+                "minimum": 0,
+                "optional": true,
+                "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab."
               }
             }
           },
           {
             "type": "function",
             "name": "callback",
             "parameters": [
               {
@@ -728,17 +733,16 @@
                 "description": "Whether the tab should be pinned."
               },
               "muted": {
                 "type": "boolean",
                 "optional": true,
                 "description": "Whether the tab should be muted."
               },
               "openerTabId": {
-                "unsupported": true,
                 "type": "integer",
                 "minimum": 0,
                 "optional": true,
                 "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab."
               }
             }
           },
           {
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -129,16 +129,17 @@ skip-if = debug || asan # Bug 1354681
 [browser_ext_tabs_lastAccessed.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_opener.js]
 [browser_ext_tabs_printPreview.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_reload.js]
 [browser_ext_tabs_reload_bypass_cache.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_cookieStoreId.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_zoom.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js
@@ -0,0 +1,78 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function() {
+  let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?1");
+  let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?2");
+
+  gBrowser.selectedTab = tab1;
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    background() {
+      let activeTab;
+      let tabId;
+      let tabIds;
+      browser.tabs.query({lastFocusedWindow: true}).then(tabs => {
+        browser.test.assertEq(3, tabs.length, "We have three tabs");
+
+        browser.test.assertTrue(tabs[1].active, "Tab 1 is active");
+        activeTab = tabs[1];
+
+        tabIds = tabs.map(tab => tab.id);
+
+        return browser.tabs.create({openerTabId: activeTab.id, active: false});
+      }).then(tab => {
+        browser.test.assertEq(activeTab.id, tab.openerTabId, "Tab opener ID is correct");
+        browser.test.assertEq(activeTab.index + 1, tab.index, "Tab was inserted after the related current tab");
+
+        tabId = tab.id;
+        return browser.tabs.get(tabId);
+      }).then(tab => {
+        browser.test.assertEq(activeTab.id, tab.openerTabId, "Tab opener ID is still correct");
+
+        return browser.tabs.update(tabId, {openerTabId: tabIds[0]});
+      }).then(tab => {
+        browser.test.assertEq(tabIds[0], tab.openerTabId, "Updated tab opener ID is correct");
+
+        return browser.tabs.get(tabId);
+      }).then(tab => {
+        browser.test.assertEq(tabIds[0], tab.openerTabId, "Updated tab opener ID is still correct");
+
+        return browser.tabs.create({openerTabId: tabId, active: false});
+      }).then(tab => {
+        browser.test.assertEq(tabId, tab.openerTabId, "New tab opener ID is correct");
+        browser.test.assertEq(tabIds.length, tab.index, "New tab was not inserted after the unrelated current tab");
+
+        let promise = browser.tabs.remove(tabId);
+
+        tabId = tab.id;
+        return promise;
+      }).then(() => {
+        return browser.tabs.get(tabId);
+      }).then(tab => {
+        browser.test.assertEq(undefined, tab.openerTabId, "Tab opener ID was cleared after opener tab closed");
+
+        return browser.tabs.remove(tabId);
+      }).then(() => {
+        browser.test.notifyPass("tab-opener");
+      }).catch(e => {
+        browser.test.fail(`${e} :: ${e.stack}`);
+        browser.test.notifyFail("tab-opener");
+      });
+    },
+  });
+
+  await extension.startup();
+
+  await extension.awaitFinish("tab-opener");
+
+  await extension.unload();
+
+  await BrowserTestUtils.removeTab(tab1);
+  await BrowserTestUtils.removeTab(tab2);
+});
--- a/toolkit/components/extensions/ExtensionTabs.jsm
+++ b/toolkit/components/extensions/ExtensionTabs.jsm
@@ -311,16 +311,25 @@ class TabBase {
    *        @readonly
    *        @abstract
    */
   get cookieStoreId() {
     throw new Error("Not implemented");
   }
 
   /**
+   * @property {integer} openerTabId
+   *        Returns the ID of the tab which opened this one.
+   *        @readonly
+   */
+  get openerTabId() {
+    return null;
+  }
+
+  /**
    * @property {integer} height
    *        Returns the pixel height of the visible area of the tab.
    *        @readonly
    *        @abstract
    */
   get height() {
     throw new Error("Not implemented");
   }
@@ -446,19 +455,19 @@ class TabBase {
    *        than an exact value match, and will do so in the future.
    * @param {MatchPattern} [queryInfo.url]
    *        Requires the tab's URL to match the given MatchPattern object.
    *
    * @returns {boolean}
    *        True if the tab matches the query.
    */
   matches(queryInfo) {
-    const PROPS = ["active", "audible", "cookieStoreId", "highlighted", "index", "pinned", "status", "title"];
+    const PROPS = ["active", "audible", "cookieStoreId", "highlighted", "index", "openerTabId", "pinned", "status", "title"];
 
-    if (PROPS.some(prop => queryInfo[prop] !== null && queryInfo[prop] !== this[prop])) {
+    if (PROPS.some(prop => queryInfo[prop] != null && queryInfo[prop] !== this[prop])) {
       return false;
     }
 
     if (queryInfo.muted !== null) {
       if (queryInfo.muted !== this.mutedInfo.muted) {
         return false;
       }
     }
@@ -499,16 +508,21 @@ class TabBase {
 
     // If the tab has not been fully layed-out yet, fallback to the geometry
     // from a different tab (usually the currently active tab).
     if (fallbackTab && (!result.width || !result.height)) {
       result.width = fallbackTab.width;
       result.height = fallbackTab.height;
     }
 
+    let opener = this.openerTabId;
+    if (opener) {
+      result.openerTabId = opener;
+    }
+
     if (this.extension.hasPermission("cookies")) {
       result.cookieStoreId = this.cookieStoreId;
     }
 
     if (this.hasTabPermission) {
       for (let prop of ["url", "title", "favIconUrl"]) {
         // We use the underscored variants here to avoid the redundant
         // permissions checks imposed on the public properties.