Bug 1500479 - Part 2: expose tab successors in browser.tabs; r=mixedpuppy,rpl
authorRyan Hendrickson <ryan.hendrickson@alum.mit.edu>
Mon, 26 Nov 2018 04:17:00 +0000
changeset 504811 cd420001c8ea7a9bb54ae85e95d9fad271ebfcf7
parent 504810 f5bf87ab64481b0121a4564f5aadf3a4c5a4d962
child 504812 2d25f33db080c14c8058a8e10c394a9139a9ea42
push id10290
push userffxbld-merge
push dateMon, 03 Dec 2018 16:23:23 +0000
treeherdermozilla-beta@700bed2445e6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy, rpl
bugs1500479
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1500479 - Part 2: expose tab successors in browser.tabs; r=mixedpuppy,rpl 1. Add successorTabId to the Tab type, so that it will be returned in, e.g., browser.tabs.get calls 2. Extend or create the following methods on the browser.tabs API: - update: add successorTabId as an optional property on the provided updateProperties object - moveInSuccession: new method that manipulates tab successors in bulk Differential Revision: https://phabricator.services.mozilla.com/D9272
browser/components/extensions/parent/ext-browser.js
browser/components/extensions/parent/ext-tabs.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_tabs_successors.js
browser/components/extensions/test/mochitest/test_ext_all_apis.html
mobile/android/components/extensions/ext-tabs.js
mobile/android/components/extensions/ext-utils.js
mobile/android/components/extensions/schemas/tabs.json
toolkit/components/extensions/parent/ext-tabs-base.js
--- a/browser/components/extensions/parent/ext-browser.js
+++ b/browser/components/extensions/parent/ext-browser.js
@@ -775,16 +775,21 @@ class Tab extends TabBase {
   get isArticle() {
     return this.nativeTab.linkedBrowser.isArticle;
   }
 
   get isInReaderMode() {
     return this.url && this.url.startsWith(READER_MODE_PREFIX);
   }
 
+  get successorTabId() {
+    const {successor} = this.nativeTab;
+    return successor ? tabTracker.getId(successor) : -1;
+  }
+
   /**
    * Converts session store data to an object compatible with the return value
    * of the convert() method, representing that data.
    *
    * @param {Extension} extension
    *        The extension for which to convert the data.
    * @param {Object} tabData
    *        Session store data for a closed tab, as returned by
--- a/browser/components/extensions/parent/ext-tabs.js
+++ b/browser/components/extensions/parent/ext-tabs.js
@@ -26,16 +26,18 @@ var {
 } = ExtensionUtils;
 
 const TABHIDE_PREFNAME = "extensions.webextensions.tabhide.enabled";
 const MULTISELECT_PREFNAME = "browser.tabs.multiselect";
 XPCOMUtils.defineLazyPreferenceGetter(this, "gMultiSelectEnabled", MULTISELECT_PREFNAME, false);
 
 const TAB_HIDE_CONFIRMED_TYPE = "tabHideNotification";
 
+const TAB_ID_NONE = -1;
+
 
 XPCOMUtils.defineLazyGetter(this, "tabHidePopup", () => {
   return new ExtensionControlledPopup({
     confirmedType: TAB_HIDE_CONFIRMED_TYPE,
     anchorId: "alltabs-button",
     popupnotificationId: "extension-tab-hide-notification",
     descriptionId: "extension-tab-hide-notification-description",
     descriptionMessageId: "tabHideControlled.message",
@@ -722,16 +724,29 @@ this.tabs = class extends ExtensionAPI {
           }
           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);
           }
+          if (updateProperties.successorTabId !== null) {
+            let successor = null;
+            if (updateProperties.successorTabId !== TAB_ID_NONE) {
+              successor = tabTracker.getTab(updateProperties.successorTabId, null);
+              if (!successor) {
+                throw new ExtensionError("Invalid successorTabId");
+              }
+              if (successor.ownerDocument !== nativeTab.ownerDocument) {
+                throw new ExtensionError("Successor tab must be in the same window as the tab being updated");
+              }
+            }
+            tabbrowser.setSuccessor(nativeTab, successor);
+          }
 
           return tabManager.convert(nativeTab);
         },
 
         async reload(tabId, reloadProperties) {
           let nativeTab = getTabOrActive(tabId);
 
           let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
@@ -1235,16 +1250,67 @@ this.tabs = class extends ExtensionAPI {
           if (!tab.isInReaderMode && !tab.isArticle) {
             throw new ExtensionError("The specified tab cannot be placed into reader mode.");
           }
           tab = getTabOrActive(tabId);
 
           tab.linkedBrowser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode");
         },
 
+        moveInSuccession(tabIds, tabId, options) {
+          const {insert, append} = options || {};
+          const tabIdSet = new Set(tabIds);
+          if (tabIdSet.size !== tabIds.length) {
+            throw new ExtensionError("IDs must not occur more than once in tabIds");
+          }
+          if ((append || insert) && tabIdSet.has(tabId)) {
+            throw new ExtensionError("Value of tabId must not occur in tabIds if append or insert is true");
+          }
+
+          const referenceTab = tabTracker.getTab(tabId, null);
+          let referenceWindow = referenceTab && referenceTab.ownerGlobal;
+          let previousTab, lastSuccessor;
+          if (append) {
+            previousTab = referenceTab;
+            lastSuccessor = (insert && referenceTab && referenceTab.successor) || null;
+          } else {
+            lastSuccessor = referenceTab;
+          }
+
+          let firstTab;
+          for (const tabId of tabIds) {
+            const tab = tabTracker.getTab(tabId, null);
+            if (tab === null) {
+              continue;
+            }
+            if (referenceWindow === null) {
+              referenceWindow = tab.ownerGlobal;
+            } else if (tab.ownerGlobal !== referenceWindow) {
+              continue;
+            }
+            referenceWindow.gBrowser.replaceInSuccession(tab, tab.successor);
+            if (append && tab === lastSuccessor) {
+              lastSuccessor = tab.successor;
+            }
+            if (previousTab) {
+              referenceWindow.gBrowser.setSuccessor(previousTab, tab);
+            } else {
+              firstTab = tab;
+            }
+            previousTab = tab;
+          }
+
+          if (previousTab) {
+            if (!append && insert && lastSuccessor !== null) {
+              referenceWindow.gBrowser.replaceInSuccession(lastSuccessor, firstTab);
+            }
+            referenceWindow.gBrowser.setSuccessor(previousTab, lastSuccessor);
+          }
+        },
+
         show(tabIds) {
           if (!Services.prefs.getBoolPref(TABHIDE_PREFNAME, false)) {
             throw new ExtensionError(`tabs.show is currently experimental and must be enabled with the ${TABHIDE_PREFNAME} preference.`);
           }
 
           if (!Array.isArray(tabIds)) {
             tabIds = [tabIds];
           }
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -97,17 +97,18 @@
           "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."},
           "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."},
           "hidden": {"type": "boolean", "optional": true, "description": "True if the tab is hidden."},
           "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."},
           "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."},
           "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."},
           "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."},
           "sharingState": {"$ref": "SharingState", "optional": true, "description": "Current tab sharing state for screen, microphone and camera."},
-          "attention": {"type": "boolean", "optional": true, "description": "Whether the tab is drawing attention."}
+          "attention": {"type": "boolean", "optional": true, "description": "Whether the tab is drawing attention."},
+          "successorTabId": {"type": "integer", "optional": true, "minimum": -1, "description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise."}
         }
       },
       {
         "id": "ZoomSettingsMode",
         "type": "string",
         "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.",
         "enum": [
           {
@@ -883,16 +884,22 @@
                 "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."
               },
               "loadReplace": {
                 "type": "boolean",
                 "optional": true,
                 "description": "Whether the load should replace the current history entry for the tab."
+              },
+              "successorTabId": {
+                "type": "integer",
+                "minimum": -1,
+                "optional": true,
+                "description": "The ID of this tab's successor. If specified, the successor tab must be in the same window as this tab."
               }
             }
           },
           {
             "type": "function",
             "name": "callback",
             "optional": true,
             "parameters": [
@@ -1391,16 +1398,58 @@
             "name": "tabIds",
             "description": "The TAB ID or list of TAB IDs to hide.",
             "choices": [
               {"type": "integer", "minimum": 0},
               {"type": "array", "items": {"type": "integer", "minimum": 0}}
             ]
           }
         ]
+      },
+      {
+        "name": "moveInSuccession",
+        "type": "function",
+        "async": true,
+        "description": "Removes an array of tabs from their lines of succession and prepends or appends them in a chain to another tab.",
+        "parameters": [
+          {
+            "name": "tabIds",
+            "type": "array",
+            "items": { "type": "integer", "minimum": 0 },
+            "minItems": 1,
+            "description": "An array of tab IDs to move in the line of succession. For each tab in the array, the tab's current predecessors will have their successor set to the tab's current successor, and each tab will then be set to be the successor of the previous tab in the array. Any tabs not in the same window as the tab indicated by the second argument (or the first tab in the array, if no second argument) will be skipped."
+          },
+          {
+            "name": "tabId",
+            "type": "integer",
+            "optional": true,
+            "default": -1,
+            "minimum": -1,
+            "description": "The ID of a tab to set as the successor of the last tab in the array, or $(ref:tabs.TAB_ID_NONE) to leave the last tab without a successor. If options.append is true, then this tab is made the predecessor of the first tab in the array instead."
+          },
+          {
+            "name": "options",
+            "type": "object",
+            "optional": true,
+            "properties": {
+              "append": {
+                "type": "boolean",
+                "optional": true,
+                "default": false,
+                "description": "Whether to move the tabs before (false) or after (true) tabId in the succession. Defaults to false."
+              },
+              "insert": {
+                "type": "boolean",
+                "optional": true,
+                "default": false,
+                "description": "Whether to link up the current predecessors or successor (depending on options.append) of tabId to the other side of the chain after it is prepended or appended. If true, one of the following happens: if options.append is false, the first tab in the array is set as the successor of any current predecessors of tabId; if options.append is true, the current successor of tabId is set as the successor of the last tab in the array. Defaults to false."
+              }
+            }
+          }
+        ]
       }
     ],
     "events": [
       {
         "name": "onCreated",
         "type": "function",
         "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
         "parameters": [
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -217,16 +217,17 @@ skip-if = (verify && !debug && (os == 'm
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_readerMode.js]
 [browser_ext_tabs_reload.js]
 [browser_ext_tabs_reload_bypass_cache.js]
 [browser_ext_tabs_saveAsPDF.js]
 skip-if = os == 'mac' # Save as PDF not supported on Mac OS X
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_sharingState.js]
+[browser_ext_tabs_successors.js]
 [browser_ext_tabs_cookieStoreId.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_update_highlighted.js]
 [browser_ext_tabs_update_url.js]
 [browser_ext_tabs_zoom.js]
 [browser_ext_themes_icons.js]
 [browser_ext_themes_validation.js]
 [browser_ext_url_overrides_newtab.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_successors.js
@@ -0,0 +1,212 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function background(tabCount, testFn) {
+  try {
+    const {TAB_ID_NONE} = browser.tabs;
+    const tabIds = await Promise.all(Array.from({length: tabCount}, () => browser.tabs.create({url: "about:blank"}).then(t => t.id)));
+
+    const toTabIds = i => tabIds[i];
+
+    const setSuccessors = mapping => Promise.all(mapping.map((succ, i) =>
+      browser.tabs.update(tabIds[i], {successorTabId: tabIds[succ]})));
+
+    const verifySuccessors = async function(mapping, name) {
+      const promises = [], expected = [];
+      for (let i = 0; i < mapping.length; i++) {
+        if (mapping[i] !== undefined) {
+          promises.push(browser.tabs.get(tabIds[i]).then(t => t.successorTabId));
+          expected.push(mapping[i] === TAB_ID_NONE ? TAB_ID_NONE : tabIds[mapping[i]]);
+        }
+      }
+      const results = await Promise.all(promises);
+      for (let i = 0; i < results.length; i++) {
+        browser.test.assertEq(expected[i], results[i], `${name}: successorTabId of tab ${i} in mapping should be ${expected[i]}`);
+      }
+    };
+
+    await testFn({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors});
+
+    browser.test.notifyPass("background-script");
+  } catch (e) {
+    browser.test.fail(`${e} :: ${e.stack}`);
+    browser.test.notifyFail("background-script");
+  }
+}
+
+async function runTabTest(tabCount, testFn) {
+  const extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+    background: `(${background})(${tabCount}, ${testFn});`,
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("background-script");
+  await extension.unload();
+}
+
+add_task(function testTabSuccessors() {
+  return runTabTest(3, async function({TAB_ID_NONE, tabIds}) {
+    const anotherWindow = await browser.windows.create({url: "about:blank"});
+
+    browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "Tabs default to an undefined successor");
+
+    // Basic getting and setting
+
+    await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]});
+    browser.test.assertEq(tabIds[1], (await browser.tabs.get(tabIds[0])).successorTabId, "tabs.update assigned the correct successor");
+
+    await browser.tabs.update(tabIds[0], {successorTabId: browser.tabs.TAB_ID_NONE});
+    browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "tabs.update cleared successor");
+
+    await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]});
+    await browser.tabs.update(tabIds[0], {successorTabId: tabIds[0]});
+    browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "Setting a tab as its own successor clears the successor instead");
+
+    // Validation tests
+
+    await browser.test.assertRejects(
+      browser.tabs.update(tabIds[0], {successorTabId: 1e8}),
+      /Invalid successorTabId/,
+      "tabs.update should throw with an invalid successor tab ID");
+
+    await browser.test.assertRejects(
+      browser.tabs.update(tabIds[0], {successorTabId: anotherWindow.tabs[0].id}),
+      /Successor tab must be in the same window as the tab being updated/,
+      "tabs.update should throw with a successor tab ID from another window");
+
+    // Make sure the successor is truly being assigned
+
+    await browser.tabs.update(tabIds[0], {successorTabId: tabIds[2], active: true});
+    await browser.tabs.remove(tabIds[0]);
+    browser.test.assertEq(tabIds[2], (await browser.tabs.query({active: true}))[0].id);
+
+
+    return browser.tabs.remove([tabIds[1], tabIds[2], anotherWindow.tabs[0].id]);
+  });
+});
+
+add_task(function testMoveInSuccession_appendFalse() {
+  return runTabTest(8, async function({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors}) {
+    await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]);
+    await verifySuccessors([TAB_ID_NONE, 0], "scenario 1");
+
+    await browser.tabs.moveInSuccession([0, 1, 2, 3].map(toTabIds), tabIds[0]);
+    await verifySuccessors([1, 2, 3, 0], "scenario 2");
+
+    await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]);
+    await verifySuccessors([TAB_ID_NONE, 0], "scenario 1 after tab 0 has a successor");
+
+    await browser.tabs.update(tabIds[7], {successorTabId: tabIds[0]});
+    await browser.tabs.moveInSuccession([4, 5, 6, 7].map(toTabIds));
+    await verifySuccessors(new Array(4).concat([5, 6, 7, TAB_ID_NONE]), "scenario 4");
+
+    await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]);
+    await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7]);
+    await verifySuccessors([7, TAB_ID_NONE, 7, 2, 6, 7, 3, 5], "scenario 5");
+
+    await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]);
+    await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7], {insert: true});
+    await verifySuccessors([4, TAB_ID_NONE, 7, 2, 6, 4, 3, 5], "insert = true");
+
+    await setSuccessors([1, 2, 3, 4, 0]);
+    await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], {insert: true});
+    await verifySuccessors([4, 2, 0, 1, 3], "insert = true, part 2");
+
+    await browser.tabs.moveInSuccession([tabIds[0], tabIds[1], 1e8, tabIds[2]]);
+    await verifySuccessors([1, 2, TAB_ID_NONE], "unknown tab ID");
+
+    browser.test.assertTrue(await browser.tabs.moveInSuccession([1e8]).then(() => true, () => false), "When all tab IDs are unknown, tabs.moveInSuccession should not throw");
+
+    // Validation tests
+
+    await browser.test.assertRejects(
+      browser.tabs.moveInSuccession([tabIds[0], tabIds[1], tabIds[0]]),
+      /IDs must not occur more than once in tabIds/,
+      "tabs.moveInSuccession should throw when a tab is referenced more than once in tabIds");
+
+    await browser.test.assertRejects(
+      browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], {insert: true}),
+      /Value of tabId must not occur in tabIds if append or insert is true/,
+      "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true");
+
+
+    return browser.tabs.remove(tabIds);
+  });
+});
+
+add_task(function testMoveInSuccession_appendTrue() {
+  return runTabTest(8, async function({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors}) {
+    await browser.tabs.moveInSuccession([1].map(toTabIds), tabIds[0], {append: true});
+    await verifySuccessors([1, TAB_ID_NONE], "scenario 1");
+
+    await browser.tabs.update(tabIds[3], {successorTabId: tabIds[4]});
+    await browser.tabs.moveInSuccession([1, 2, 3].map(toTabIds), tabIds[0], {append: true});
+    await verifySuccessors([1, 2, 3, TAB_ID_NONE], "scenario 2");
+
+    await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]});
+    await browser.tabs.moveInSuccession([1e8], tabIds[0], {append: true});
+    browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "If no tabs get appended after the reference tab, it should lose its successor");
+
+    await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]);
+    await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7], {append: true});
+    await verifySuccessors([7, TAB_ID_NONE, TAB_ID_NONE, 2, 6, 7, 3, 4], "scenario 3");
+
+    await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]);
+    await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7], {append: true, insert: true});
+    await verifySuccessors([7, TAB_ID_NONE, 5, 2, 6, 7, 3, 4], "insert = true");
+
+    await browser.tabs.moveInSuccession([0, 4].map(toTabIds), tabIds[7], {append: true, insert: true});
+    await verifySuccessors([4, undefined, undefined, undefined, 6, undefined, undefined, 0], "insert = true, part 2");
+
+    await setSuccessors([1, 2, 3, 4, 0]);
+    await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], {append: true, insert: true});
+    await verifySuccessors([3, 2, 4, 1, 0], "insert = true, part 3");
+
+    await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]});
+    await browser.tabs.moveInSuccession([1e8], tabIds[0], {append: true, insert: true});
+    browser.test.assertEq(tabIds[1], (await browser.tabs.get(tabIds[0])).successorTabId, "If no tabs get inserted after the reference tab, it should keep its successor");
+
+    // Validation tests
+
+    await browser.test.assertRejects(
+      browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], {append: true}),
+      /Value of tabId must not occur in tabIds if append or insert is true/,
+      "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true");
+
+
+    return browser.tabs.remove(tabIds);
+  });
+});
+
+add_task(function testMoveInSuccession_ignoreTabsInOtherWindows() {
+  return runTabTest(2, async function({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors}) {
+    const anotherWindow = await browser.windows.create({url: Array.from({length: 3}, () => "about:blank")});
+    tabIds.push(...anotherWindow.tabs.map(t => t.id));
+
+    await setSuccessors([1, 0, 3, 4, 2]);
+    await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4]);
+    await verifySuccessors([1, 0, 4, 2, TAB_ID_NONE], "first tab in another window");
+
+    await setSuccessors([1, 0, 3, 4, 2]);
+    await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4]);
+    await verifySuccessors([1, 0, 4, 2, TAB_ID_NONE], "middle tab in another window");
+
+    await setSuccessors([1, 0, 3, 4, 2]);
+    await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds));
+    await verifySuccessors([1, 0, TAB_ID_NONE, 2, TAB_ID_NONE], "using the first tab to determine the window");
+
+    await setSuccessors([1, 0, 3, 4, 2]);
+    await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4], {append: true});
+    await verifySuccessors([1, 0, TAB_ID_NONE, 2, 3], "first tab in another window, appending");
+
+    await setSuccessors([1, 0, 3, 4, 2]);
+    await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4], {append: true});
+    await verifySuccessors([1, 0, TAB_ID_NONE, 2, 3], "middle tab in another window, appending");
+
+    return browser.tabs.remove(tabIds);
+  });
+});
--- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -31,16 +31,17 @@ let expectedBackgroundApisTargetSpecific
   "tabs.executeScript",
   "tabs.get",
   "tabs.getCurrent",
   "tabs.getZoom",
   "tabs.getZoomSettings",
   "tabs.highlight",
   "tabs.insertCSS",
   "tabs.move",
+  "tabs.moveInSuccession",
   "tabs.onActivated",
   "tabs.onAttached",
   "tabs.onCreated",
   "tabs.onDetached",
   "tabs.onHighlighted",
   "tabs.onMoved",
   "tabs.onRemoved",
   "tabs.onReplaced",
--- a/mobile/android/components/extensions/ext-tabs.js
+++ b/mobile/android/components/extensions/ext-tabs.js
@@ -331,17 +331,17 @@ this.tabs = class extends ExtensionAPI {
 
           if (updateProperties.active !== null) {
             if (updateProperties.active) {
               BrowserApp.selectTab(nativeTab);
             } else {
               // Not sure what to do here? Which tab should we select?
             }
           }
-          // FIXME: highlighted/selected, muted, pinned, openerTabId
+          // FIXME: highlighted/selected, muted, pinned, openerTabId, successorTabId
 
           return tabManager.convert(nativeTab);
         },
 
         async reload(tabId, reloadProperties) {
           let nativeTab = getTabOrActive(tabId);
 
           let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
--- a/mobile/android/components/extensions/ext-utils.js
+++ b/mobile/android/components/extensions/ext-utils.js
@@ -545,16 +545,20 @@ class Tab extends TabBase {
 
   get status() {
     if (this.browser.webProgress.isLoadingDocument) {
       return "loading";
     }
     return "complete";
   }
 
+  get successorTabId() {
+    return -1;
+  }
+
   get width() {
     return this.browser.clientWidth;
   }
 
   get window() {
     return this.browser.ownerGlobal;
   }
 
--- a/mobile/android/components/extensions/schemas/tabs.json
+++ b/mobile/android/components/extensions/schemas/tabs.json
@@ -96,17 +96,18 @@
           "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."},
           "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."},
           "hidden": {"type": "boolean", "optional": true, "description": "True if the tab is hidden."},
           "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."},
           "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."},
           "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."},
           "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."},
           "sharingState": {"$ref": "SharingState", "optional": true, "description": "Current tab sharing state for screen, microphone and camera."},
-          "attention": {"type": "boolean", "optional": true, "description": "Whether the tab is drawing attention."}
+          "attention": {"type": "boolean", "optional": true, "description": "Whether the tab is drawing attention."},
+          "successorTabId": {"type": "integer", "optional": true, "minimum": -1, "description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise."}
         }
       },
       {
         "id": "ZoomSettingsMode",
         "type": "string",
         "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.",
         "enum": [
           {
--- a/toolkit/components/extensions/parent/ext-tabs-base.js
+++ b/toolkit/components/extensions/parent/ext-tabs-base.js
@@ -491,16 +491,25 @@ class TabBase {
    *        @readonly
    *        @abstract
    */
   get isInReaderMode() {
     throw new Error("Not implemented");
   }
 
   /**
+   * @property {integer} successorTabId
+   *        @readonly
+   *        @abstract
+   */
+  get successorTabId() {
+    throw new Error("Not implemented");
+  }
+
+  /**
    * Returns true if this tab matches the the given query info object. Omitted
    * or null have no effect on the match.
    *
    * @param {object} queryInfo
    *        The query info against which to match.
    * @param {boolean} [queryInfo.active]
    *        Matches against the exact value of the tab's `active` attribute.
    * @param {boolean} [queryInfo.audible]
@@ -604,16 +613,17 @@ class TabBase {
       width: this.width,
       height: this.height,
       lastAccessed: this.lastAccessed,
       audible: this.audible,
       mutedInfo: this.mutedInfo,
       isArticle: this.isArticle,
       isInReaderMode: this.isInReaderMode,
       sharingState: this.sharingState,
+      successorTabId: this.successorTabId,
     };
 
     // If the tab has not been fully layed-out yet, fallback to the geometry
     // from a different tab (usually the currently active tab).
     if (fallbackTabSize && (!result.width || !result.height)) {
       result.width = fallbackTabSize.width;
       result.height = fallbackTabSize.height;
     }