Bug 1238314: [webext] Implement tabs API `openerTabId` properties. r?billm draft
authorKris Maglione <maglione.k@gmail.com>
Thu, 04 Feb 2016 21:58:46 -0800
changeset 333226 4b3e211c089f74a1fe6a8bc447a8f2790f5a0dd5
parent 332761 ba617d51a0fdc41e619302fd750376aeaabf86ad
child 514672 d083d61d3f82d4543b1eb29452ae7eb1d202fcaa
push id11299
push usermaglione.k@gmail.com
push dateTue, 23 Feb 2016 00:37:54 +0000
reviewersbillm
bugs1238314
milestone47.0a1
Bug 1238314: [webext] Implement tabs API `openerTabId` properties. r?billm MozReview-Commit-ID: 8PLsV8h4gRR
browser/components/extensions/ext-tabs.js
browser/components/extensions/ext-utils.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_tabs_opener.js
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -392,26 +392,35 @@ extensions.registerSchemaAPI("tabs", nul
           AllWindowEvents.removeListener("progress", progressListener);
           AllWindowEvents.removeListener("TabAttrModified", listener);
           AllWindowEvents.removeListener("TabPinned", listener);
           AllWindowEvents.removeListener("TabUnpinned", listener);
         };
       }).api(),
 
       create: function(createProperties) {
-        return new Promise(resolve => {
+        return new Promise((resolve, reject) => {
           function createInWindow(window) {
             let url;
             if (createProperties.url !== null) {
               url = context.uri.resolve(createProperties.url);
             } else {
               url = window.BROWSER_NEW_TAB_URL;
             }
 
-            let tab = window.gBrowser.addTab(url);
+            let ownerTab;
+            if (createProperties.openerTabId !== null) {
+              ownerTab = TabManager.getTab(createProperties.openerTabId);
+              if (ownerTab.ownerDocument.defaultView !== window) {
+                return reject({message: "Opener tab must be in the same window as the tab being created"});
+              }
+            }
+
+            let relatedToCurrent = ownerTab == window.gBrowser.selectedTab;
+            let tab = window.gBrowser.addTab(url, {ownerTab, relatedToCurrent});
 
             let active = true;
             if (createProperties.active !== null) {
               active = createProperties.active;
             }
             if (active) {
               window.gBrowser.selectedTab = tab;
             }
@@ -478,17 +487,26 @@ extensions.registerSchemaAPI("tabs", nul
         }
         if (updateProperties.pinned !== null) {
           if (updateProperties.pinned) {
             tabbrowser.pinTab(tab);
           } else {
             tabbrowser.unpinTab(tab);
           }
         }
-        // FIXME: highlighted/selected, openerTabId
+
+        if (updateProperties.openerTabId !== null) {
+          let opener = TabManager.getTab(updateProperties.openerTabId);
+          if (opener.ownerDocument !== tab.ownerDocument) {
+            return Promise.reject({message: "Opener tab must be in the same window as the tab being updated"});
+          }
+          TabManager.setOpener(tab, opener);
+        }
+
+        // FIXME: highlighted/selected
 
         return Promise.resolve(TabManager.convert(extension, tab));
       },
 
       reload: function(tabId, reloadProperties) {
         let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
         let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
         if (reloadProperties && reloadProperties.bypassCache) {
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -442,16 +442,21 @@ ExtensionTabManager.prototype = {
       status: TabManager.getStatus(tab),
       incognito: PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser),
       width: tab.linkedBrowser.clientWidth,
       height: tab.linkedBrowser.clientHeight,
       audible: tab.soundPlaying,
       mutedInfo,
     };
 
+    let opener = TabManager.getOpener(tab);
+    if (opener != null) {
+      result.openerTabId = opener;
+    }
+
     if (this.hasTabPermission(tab)) {
       result.url = tab.linkedBrowser.currentURI.spec;
       if (tab.linkedBrowser.contentTitle) {
         result.title = tab.linkedBrowser.contentTitle;
       }
       let icon = window.gBrowser.getIcon(tab);
       if (icon) {
         result.favIconUrl = icon;
@@ -464,17 +469,22 @@ ExtensionTabManager.prototype = {
   getTabs(window) {
     return Array.from(window.gBrowser.tabs, tab => this.convert(tab));
   },
 };
 
 
 // Manages global mappings between XUL tabs and extension tab IDs.
 global.TabManager = {
+  // A mapping of <tab> elements to their IDs.
+  // WeakMap[<tab> -> number]
   _tabs: new WeakMap(),
+  // A mapping of <tab> elements to the IDs of their openers.
+  // WeakMap[<tab> -> number]
+  _tabOpeners: new WeakMap(),
   _nextId: 1,
   _initialized: false,
 
   // We begin listening for TabOpen and TabClose events once we've started
   // assigning IDs to tabs, so that we can remap the IDs of tabs which are moved
   // between windows.
   initListener() {
     if (this._initialized) {
@@ -561,16 +571,51 @@ global.TabManager = {
   get activeTab() {
     let window = WindowManager.topWindow;
     if (window && window.gBrowser) {
       return window.gBrowser.selectedTab;
     }
     return null;
   },
 
+  /**
+   * 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 {<tab>} tab The tab for which to find an owner.
+   * @returns {number|null}
+   */
+  getOpener(tab) {
+    let opener = tab.owner || this._tabOpeners.get(tab);
+    if (opener && opener.parentNode && opener.ownerDocument === tab.ownerDocument) {
+      // If this came from the `.owner` property, make sure we cache the value
+      // so it persists after that property is cleared.
+      this._tabOpeners.set(tab, opener);
+      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 {<tab>} tab The tab for which to set the owner.
+   * @param {<tab>} openerTab The opener of <tab>.
+   */
+  setOpener(tab, openerTab) {
+    if (tab.ownerDocument !== openerTab.ownerDocument) {
+      throw new TypeError("Tab must be in the same window as its opener");
+    }
+    tab.owner = openerTab;
+    this._tabOpeners.set(tab, openerTab);
+  },
+
   getStatus(tab) {
     return tab.getAttribute("busy") == "true" ? "loading" : "complete";
   },
 
   convert(extension, tab) {
     return TabManager.for(extension).convert(tab);
   },
 };
--- 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", "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."},
           "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."},
           "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, "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
           "title": {"type": "string", "optional": true, "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
@@ -379,17 +379,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."
               }
             }
           },
           {
@@ -615,17 +614,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.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -30,16 +30,17 @@ support-files =
 [browser_ext_tabs_executeScript_bad.js]
 [browser_ext_tabs_insertCSS.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_getCurrent.js]
 [browser_ext_tabs_create.js]
 [browser_ext_tabs_duplicate.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_onUpdated.js]
+[browser_ext_tabs_opener.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_move.js]
 [browser_ext_tabs_move_window.js]
 [browser_ext_windows_create_tabId.js]
 [browser_ext_windows_update.js]
 [browser_ext_contentscript_connect.js]
 [browser_ext_tab_runtimeConnect.js]
 [browser_ext_webNavigation_getFrames.js]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js
@@ -0,0 +1,80 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(function* () {
+  let tab1 = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?1");
+  let tab2 = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?2");
+
+  gBrowser.selectedTab = tab1;
+
+  function background() {
+    let activeTab;
+    let tabId;
+    let tabIds;
+    browser.tabs.query({lastFocusedWindow: true}).then(tabs => {
+      browser.test.assertEq(tabs.length, 3, "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 + 1, 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");
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    background,
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitFinish("tab-opener");
+
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab1);
+  yield BrowserTestUtils.removeTab(tab2);
+});