Bug 1602176: When available use a site's app manifest to determine what is inside and outside of an SSB. r=Gijs
authorDave Townsend <dtownsend@oxymoronical.com>
Fri, 13 Dec 2019 15:49:26 +0000
changeset 506918 26309a2800ed7d61c5b4625c534d050ead20f7cd
parent 506917 c2fb51adf8d9d0b5213a1fde3ba7e5cc9847983f
child 506919 c836766f6842cad238bd43b931ca52f006f6a8f4
push id36915
push userrgurzau@mozilla.com
push dateFri, 13 Dec 2019 21:43:22 +0000
treeherdermozilla-central@f09f24f2b545 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1602176
milestone73.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 1602176: When available use a site's app manifest to determine what is inside and outside of an SSB. r=Gijs When launched from a browser we can retrieve a site manifest to provide more information about the site. Differential Revision: https://phabricator.services.mozilla.com/D56287
browser/base/content/browser-pageActions.js
browser/components/ssb/SiteSpecificBrowserService.jsm
browser/components/ssb/content/ssb.js
browser/components/ssb/moz.build
browser/components/ssb/tests/browser/browser.ini
browser/components/ssb/tests/browser/browser_ssb_manifest_scope.js
browser/components/ssb/tests/browser/head.js
browser/components/ssb/tests/browser/site1/allhost.html
browser/components/ssb/tests/browser/site1/allhost.json
browser/components/ssb/tests/browser/site1/empty.html
browser/components/ssb/tests/browser/site1/empty.json
browser/components/ssb/tests/browser/site1/final.html
browser/components/ssb/tests/browser/site1/simple.html
browser/components/ssb/tests/browser/site2/final.html
browser/components/ssb/tests/xpcshell/head.js
browser/components/ssb/tests/xpcshell/test_manifest.js
browser/components/ssb/tests/xpcshell/xpcshell.ini
--- a/browser/base/content/browser-pageActions.js
+++ b/browser/base/content/browser-pageActions.js
@@ -1,15 +1,15 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 ChromeUtils.defineModuleGetter(
   this,
-  "SiteSpecificBrowserService",
+  "SiteSpecificBrowser",
   "resource:///modules/SiteSpecificBrowserService.jsm"
 );
 
 var BrowserPageActions = {
   /**
    * The main page action button in the urlbar (DOM node)
    */
   get mainButtonNode() {
@@ -1094,22 +1094,28 @@ BrowserPageActions.pinTab = {
 // SiteSpecificBrowser
 BrowserPageActions.launchSSB = {
   updateState() {
     let action = PageActions.actionForID("launchSSB");
     let browser = gBrowser.selectedBrowser;
     action.setDisabled(!browser.currentURI.schemeIs("https"), window);
   },
 
-  onCommand(event, buttonNode) {
+  async onCommand(event, buttonNode) {
     if (!gBrowser.currentURI.schemeIs("https")) {
       return;
     }
 
-    SiteSpecificBrowserService.launchFromURI(gBrowser.currentURI);
+    let ssb = await SiteSpecificBrowser.createFromBrowser(
+      gBrowser.selectedBrowser
+    );
+
+    // The site's manifest may point to a different start page so explicitly
+    // open the SSB to the current page.
+    ssb.launch(gBrowser.selectedBrowser.currentURI);
     gBrowser.removeTab(gBrowser.selectedTab, { closeWindowWithLastTab: false });
   },
 };
 
 // copy URL
 BrowserPageActions.copyURL = {
   onBeforePlacedInWindow(browserWindow) {
     let action = PageActions.actionForID("copyURL");
--- a/browser/components/ssb/SiteSpecificBrowserService.jsm
+++ b/browser/components/ssb/SiteSpecificBrowserService.jsm
@@ -28,53 +28,100 @@ var EXPORTED_SYMBOLS = [
   "SSBCommandLineHandler",
 ];
 
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
+XPCOMUtils.defineLazyModuleGetters(this, {
+  ManifestObtainer: "resource://gre/modules/ManifestObtainer.jsm",
+  ManifestProcessor: "resource://gre/modules/ManifestProcessor.jsm",
+});
+
 function uuid() {
   return Cc["@mozilla.org/uuid-generator;1"]
     .getService(Ci.nsIUUIDGenerator)
     .generateUUID()
     .toString();
 }
 
 const sharedDataKey = id => `SiteSpecificBrowserBase:${id}`;
 
 const IS_MAIN_PROCESS =
   Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT;
 
 /**
+ * Generates a basic app manifest for a URI.
+ *
+ * @param {nsIURI} uri the start URI for the site.
+ * @return {Manifest} an app manifest.
+ */
+function manifestForURI(uri) {
+  try {
+    let manifestURI = Services.io.newURI("/manifest.json", null, uri);
+    return ManifestProcessor.process({
+      jsonText: "{}",
+      manifestURL: manifestURI.spec,
+      docURL: uri.spec,
+    });
+  } catch (e) {
+    console.error(`Failed to generate a SSB manifest for ${uri.spec}.`, e);
+    throw e;
+  }
+}
+
+/**
+ * Generates an app manifest for a site loaded in a browser element.
+ *
+ * @param {Element} browser the browser element the site is loaded in.
+ * @return {Promise<Manifest>} an app manifest.
+ */
+async function buildManifestForBrowser(browser) {
+  let manifest = null;
+  try {
+    manifest = await ManifestObtainer.browserObtainManifest(browser);
+  } catch (e) {
+    // We can function without a valid manifest.
+    console.error(e);
+  }
+
+  if (!manifest) {
+    manifest = manifestForURI(browser.currentURI);
+  }
+
+  return manifest;
+}
+
+/**
  * Maintains an ID -> SSB mapping in the main process. Content processes should
  * use sharedData to get a SiteSpecificBrowserBase.
  *
  * We do not currently expire data from here so once created an SSB instance
  * lives for the lifetime of the application. The expectation is that the
  * numbers of different SSBs used will be low and the memory use will also
  * be low.
  */
 const SSBMap = new Map();
 
 /**
  * The base contains the data about an SSB instance needed in content processes.
  *
- * The only data needed currently is the URI used to launch the SSB.
+ * The only data needed currently is site's `scope` which is just a URI.
  */
 class SiteSpecificBrowserBase {
   /**
    * Creates a new SiteSpecificBrowserBase. Generally should only be called by
    * code within this module.
    *
-   * @param {nsIURI} uri the base URI for the SSB.
+   * @param {nsIURI} scope the scope for the SSB.
    */
-  constructor(uri) {
-    this._uri = uri;
+  constructor(scope) {
+    this._scope = scope;
   }
 
   /**
    * Gets the SiteSpecifcBrowserBase for an ID. If this is the main process this
    * will instead return the SiteSpecificBrowser instance itself but generally
    * don't call this from the main process.
    *
    * The returned object is not "live" and will not be updated with any
@@ -89,65 +136,78 @@ class SiteSpecificBrowserBase {
       return SiteSpecificBrowser.get(id);
     }
 
     let key = sharedDataKey(id);
     if (!Services.cpmm.sharedData.has(key)) {
       return null;
     }
 
-    let uri = Services.io.newURI(Services.cpmm.sharedData.get(key));
-    return new SiteSpecificBrowserBase(uri);
+    let scope = Services.io.newURI(Services.cpmm.sharedData.get(key));
+    return new SiteSpecificBrowserBase(scope);
   }
 
   /**
    * Checks whether the given URI is considered to be a part of this SSB or not.
    * Any URIs that return false should be loaded in a normal browser.
    *
    * @param {nsIURI} uri the URI to check.
    * @return {boolean} whether this SSB can load the URI.
    */
   canLoad(uri) {
     // Always allow loading about:blank as it is the initial page for iframes.
     if (uri.spec == "about:blank") {
       return true;
     }
 
-    // A simplistic check. Is this a uri from the same origin.
-    return uri.prePath == this._uri.prePath;
+    // https://w3c.github.io/manifest/#dfn-within-scope
+    if (this._scope.prePath != uri.prePath) {
+      return false;
+    }
+
+    return uri.filePath.startsWith(this._scope.filePath);
   }
 }
 
 /**
  * The SSB instance used in the main process.
+ *
+ * We maintain two pieces of data for an SSB:
+ *
+ * First is the string UUID for identification purposes.
+ *
+ * Second is an app manifest (https://w3c.github.io/manifest/). If the site does
+ * not provide one a basic one will be automatically generated.
+ *
+ * We pass data based on these down to the SiteSpecificBrowserBase in this and
+ * other processes (via `_updateSharedData`).
  */
 class SiteSpecificBrowser extends SiteSpecificBrowserBase {
   /**
    * Creates a new SiteSpecificBrowser. Generally should only be called by
    * code within this module.
    *
-   * @param {string} id  the SSB's unique ID.
-   * @param {nsIURI} uri the base URI for the SSB.
+   * @param {string} id the SSB's unique ID.
+   * @param {Manifest} manifest the app manifest for the SSB.
    */
-  constructor(id, uri) {
+  constructor(id, manifest) {
     if (!IS_MAIN_PROCESS) {
       throw new Error(
         "SiteSpecificBrowser instances are only available in the main process."
       );
     }
 
-    super(uri);
+    super(Services.io.newURI(manifest.scope));
     this._id = id;
+    this._manifest = manifest;
 
     // Cache the SSB for retrieval.
     SSBMap.set(id, this);
 
-    // Cache the data that the content processes need.
-    Services.ppmm.sharedData.set(sharedDataKey(id), this._uri.spec);
-    Services.ppmm.sharedData.flush();
+    this._updateSharedData();
   }
 
   /**
    * Gets the SiteSpecifcBrowser for an ID. Can only be called from the main
    * process.
    *
    * @param {string} id the SSB ID.
    * @return {SiteSpecificBrowser|null} the instance if it exists.
@@ -158,70 +218,132 @@ class SiteSpecificBrowser extends SiteSp
         "SiteSpecificBrowser instances are only available in the main process."
       );
     }
 
     return SSBMap.get(id);
   }
 
   /**
+   * Creates an SSB from a parsed app manifest.
+   *
+   * @param {Manifest} manifest the app manifest for the site.
+   * @return {Promise<SiteSpecificBrowser>} the generated SSB.
+   */
+  static async createFromManifest(manifest) {
+    if (!SiteSpecificBrowserService.isEnabled) {
+      throw new Error("Site specific browsing is disabled.");
+    }
+
+    if (!manifest.scope.startsWith("https:")) {
+      throw new Error(
+        "Site specific browsers can only be opened for secure sites."
+      );
+    }
+
+    return new SiteSpecificBrowser(uuid(), manifest);
+  }
+
+  /**
+   * Creates an SSB from a site loaded in a browser element.
+   *
+   * @param {Element} browser the browser element the site is loaded in.
+   * @return {Promise<SiteSpecificBrowser>} the generated SSB.
+   */
+  static async createFromBrowser(browser) {
+    if (!SiteSpecificBrowserService.isEnabled) {
+      throw new Error("Site specific browsing is disabled.");
+    }
+
+    if (!browser.currentURI.schemeIs("https")) {
+      throw new Error(
+        "Site specific browsers can only be opened for secure sites."
+      );
+    }
+
+    return SiteSpecificBrowser.createFromManifest(
+      await buildManifestForBrowser(browser)
+    );
+  }
+
+  /**
+   * Creates an SSB from a sURI.
+   *
+   * @param {nsIURI} uri the uri to generate from.
+   * @return {SiteSpecificBrowser} the generated SSB.
+   */
+  static createFromURI(uri) {
+    if (!SiteSpecificBrowserService.isEnabled) {
+      throw new Error("Site specific browsing is disabled.");
+    }
+
+    if (!uri.schemeIs("https")) {
+      throw new Error(
+        "Site specific browsers can only be opened for secure sites."
+      );
+    }
+
+    return new SiteSpecificBrowser(uuid(), manifestForURI(uri));
+  }
+
+  /**
+   * Caches the data needed by content processes.
+   */
+  _updateSharedData() {
+    Services.ppmm.sharedData.set(sharedDataKey(this.id), this._scope.spec);
+    Services.ppmm.sharedData.flush();
+  }
+
+  /**
    * The SSB's ID.
    */
   get id() {
     return this._id;
   }
 
   /**
    * The default URI to load.
    */
   get startURI() {
-    return this._uri;
+    return Services.io.newURI(this._manifest.start_url);
   }
 
   /**
    * Launches a SSB by opening the necessary UI.
+   *
+   * @param {nsIURI?} the initial URI to load. If not provided uses the default.
    */
-  launch() {
+  launch(uri = null) {
     let sa = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+
     let idstr = Cc["@mozilla.org/supports-string;1"].createInstance(
       Ci.nsISupportsString
     );
     idstr.data = this.id;
     sa.appendElement(idstr);
+
+    if (uri) {
+      let uristr = Cc["@mozilla.org/supports-string;1"].createInstance(
+        Ci.nsISupportsString
+      );
+      uristr.data = uri.spec;
+      sa.appendElement(uristr);
+    }
+
     Services.ww.openWindow(
       null,
       "chrome://browser/content/ssb/ssb.html",
       "_blank",
       "chrome,dialog=no,all",
       sa
     );
   }
 }
 
-const SiteSpecificBrowserService = {
-  /**
-   * Given a URI launches the SSB UI to display it.
-   *
-   * @param {nsIURI} uri the URI to display.
-   */
-  launchFromURI(uri) {
-    if (!this.isEnabled) {
-      throw new Error("Site specific browsing is disabled.");
-    }
-
-    if (!uri.schemeIs("https")) {
-      throw new Error(
-        "Site specific browsers can only be opened for secure sites."
-      );
-    }
-
-    let ssb = new SiteSpecificBrowser(uuid(), uri);
-    ssb.launch();
-  },
-};
+const SiteSpecificBrowserService = {};
 
 XPCOMUtils.defineLazyPreferenceGetter(
   SiteSpecificBrowserService,
   "isEnabled",
   "browser.ssb.enabled",
   false
 );
 
@@ -249,17 +371,18 @@ class SSBCommandLineHandler {
         }
 
         if (fixupInfo.fixupChangedProtocol && uri.schemeIs("http")) {
           uri = uri
             .mutate()
             .setScheme("https")
             .finalize();
         }
-        SiteSpecificBrowserService.launchFromURI(uri);
+        let ssb = SiteSpecificBrowser.createFromURI(uri);
+        ssb.launch();
       } catch (e) {
         dump(`Unable to parse '${site}' as a URI: ${e}\n`);
       }
     }
   }
 
   get helpInfo() {
     return "  --ssb <uri>        Open a site specific browser for <uri>.\n";
--- a/browser/components/ssb/content/ssb.js
+++ b/browser/components/ssb/content/ssb.js
@@ -14,32 +14,37 @@ XPCOMUtils.defineLazyModuleGetters(this,
 });
 
 let gSSBBrowser = null;
 let gSSB = null;
 
 function init() {
   gSSB = SiteSpecificBrowser.get(window.arguments[0]);
 
+  let uri = gSSB.startURI;
+  if (window.arguments.length > 1) {
+    uri = Services.io.newURI(window.arguments[1]);
+  }
+
   window.browserDOMWindow = new BrowserDOMWindow();
 
   gSSBBrowser = document.createXULElement("browser");
   gSSBBrowser.setAttribute("id", "browser");
   gSSBBrowser.setAttribute("type", "content");
   gSSBBrowser.setAttribute("remote", "true");
   gSSBBrowser.setAttribute("nodefaultsrc", "true");
   document.getElementById("browser-container").appendChild(gSSBBrowser);
 
   // Give our actor the SSB's ID.
   let actor = gSSBBrowser.browsingContext.currentWindowGlobal.getActor(
     "SiteSpecificBrowser"
   );
   actor.sendAsyncMessage("SetSSB", gSSB.id);
 
-  gSSBBrowser.src = gSSB.startURI.spec;
+  gSSBBrowser.src = uri.spec;
 }
 
 class BrowserDOMWindow {
   /**
    * Called when a page in the main process needs a new window to display a new
    * page in.
    *
    * @param {nsIURI?} uri
--- a/browser/components/ssb/moz.build
+++ b/browser/components/ssb/moz.build
@@ -1,16 +1,17 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 JAR_MANIFESTS += ['content/jar.mn']
 BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini']
+XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini']
 
 XPCOM_MANIFESTS += [
     'components.conf',
 ]
 
 EXTRA_JS_MODULES += [
     'SiteSpecificBrowserService.jsm',
 ]
--- a/browser/components/ssb/tests/browser/browser.ini
+++ b/browser/components/ssb/tests/browser/browser.ini
@@ -3,15 +3,19 @@ support-files =
   head.js
   test_page.html
   empty_page.html
 prefs =
   browser.ssb.enabled=true
 
 [browser_ssb_direct.js]
 [browser_ssb_lasttab.js]
+[browser_ssb_manifest_scope.js]
+support-files =
+  site1/*
+  site2/*
 [browser_ssb_menu.js]
 [browser_ssb_newtab.js]
 [browser_ssb_newwindow.js]
 [browser_ssb_open.js]
 [browser_ssb_windowlocation.js]
 [browser_ssb_windowopen.js]
 skip-if = true # It is unclear what we want to do here.
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/browser_ssb_manifest_scope.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Check that a site's manifest affects the scope of a ssb.
+
+function build_task(page, linkId, external) {
+  let expectedTarget = linkId + "/final.html";
+
+  add_task(async () => {
+    await BrowserTestUtils.openNewForegroundTab({
+      gBrowser,
+      url: gHttpsTestRoot + page,
+    });
+
+    let ssb = await openSSBFromBrowserWindow();
+
+    let promise;
+    if (external) {
+      promise = expectTabLoad(ssb).then(tab => {
+        Assert.equal(
+          tab.linkedBrowser.currentURI.spec,
+          gHttpsTestRoot + expectedTarget,
+          "Should have loaded the right uri."
+        );
+        BrowserTestUtils.removeTab(tab);
+      });
+    } else {
+      promise = expectSSBLoad(ssb).then(() => {
+        Assert.equal(
+          getBrowser(ssb).currentURI.spec,
+          gHttpsTestRoot + expectedTarget,
+          "Should have loaded the right uri."
+        );
+      });
+    }
+
+    await BrowserTestUtils.synthesizeMouseAtCenter(
+      `#${linkId}`,
+      {},
+      getBrowser(ssb)
+    );
+
+    await promise;
+    await BrowserTestUtils.closeWindow(ssb);
+  });
+}
+
+/**
+ * Arguments are:
+ *
+ * * Page to load.
+ * * Link ID to click in the page.
+ * * Is that link expected to point to an external site (i.e. should be retargeted).
+ */
+build_task("site1/simple.html", "site1", false);
+build_task("site1/simple.html", "site2", true);
+build_task("site1/empty.html", "site1", false);
+build_task("site1/empty.html", "site2", true);
+build_task("site1/allhost.html", "site1", false);
+build_task("site1/allhost.html", "site2", false);
--- a/browser/components/ssb/tests/browser/head.js
+++ b/browser/components/ssb/tests/browser/head.js
@@ -1,11 +1,15 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
+const { SiteSpecificBrowser } = ChromeUtils.import(
+  "resource:///modules/SiteSpecificBrowserService.jsm"
+);
+
 // An insecure site to use. SSBs cannot be insecure.
 const gHttpTestRoot = getRootDirectory(gTestPath).replace(
   "chrome://mochitests/content/",
   "http://example.com/"
 );
 
 // A secure site to use.
 const gHttpsTestRoot = getRootDirectory(gTestPath).replace(
@@ -29,17 +33,18 @@ async function openSSB(uri) {
     uri = Services.io.newURI(uri);
   }
 
   let openPromise = BrowserTestUtils.domWindowOpened(null, async domwin => {
     await BrowserTestUtils.waitForEvent(domwin, "load");
     return domwin.location.toString() == SSB_WINDOW;
   });
 
-  SiteSpecificBrowserService.launchFromURI(uri);
+  let ssb = SiteSpecificBrowser.createFromURI(uri);
+  ssb.launch();
 
   let ssbwin = await openPromise;
   await BrowserTestUtils.browserLoaded(getBrowser(ssbwin), true, uri.spec);
   return ssbwin;
 }
 
 // Simulates opening a SSB from the main browser window. Resolves to the SSB
 // DOM window after the SSB content has loaded.
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/site1/allhost.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+<meta charset="utf-8">
+<!-- This page links to a manifest that sets the scope to the entire host. -->
+<link rel="manifest" href="allhost.json">
+</head>
+<body>
+<p><a id="site1" href="../site1/final.html">Site 1</a></p>
+<p><a id="site2" href="../site2/final.html">Site 2</a></p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/site1/allhost.json
@@ -0,0 +1,3 @@
+{
+  "scope": "/"
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/site1/empty.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+<meta charset="utf-8">
+<!-- This page links to an empty manifest. The default scope is the current
+     directory -->
+<link rel="manifest" href="empty.json">
+</head>
+<body>
+<p><a id="site1" href="../site1/final.html">Site 1</a></p>
+<p><a id="site2" href="../site2/final.html">Site 2</a></p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/site1/empty.json
@@ -0,0 +1,1 @@
+{}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/site1/final.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<p>Landing page</p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/site1/simple.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+<meta charset="utf-8">
+<!-- This page has no linked manifest. A missing manifest is treated as the same
+     as an empty manifest where the default scope is the current directory -->
+</head>
+<body>
+<p><a id="site1" href="../site1/final.html">Site 1</a></p>
+<p><a id="site2" href="../site2/final.html">Site 2</a></p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/browser/site2/final.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<p>Landing page</p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/xpcshell/head.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { SiteSpecificBrowser, SiteSpecificBrowserService } = ChromeUtils.import(
+  "resource:///modules/SiteSpecificBrowserService.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  ManifestProcessor: "resource://gre/modules/ManifestProcessor.jsm",
+});
+
+const uri = spec => Services.io.newURI(spec);
+
+Services.prefs.setBoolPref("browser.ssb.enabled", true);
+
+function parseManifest(doc, manifest = {}) {
+  return ManifestProcessor.process({
+    jsonText: JSON.stringify(manifest),
+    manifestURL: new URL("/manifest.json", doc),
+    docURL: doc,
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/xpcshell/test_manifest.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function empty_manifest() {
+  let ssb = await SiteSpecificBrowser.createFromManifest(
+    parseManifest("https://www.mozilla.org/")
+  );
+
+  Assert.equal(ssb.startURI.spec, "https://www.mozilla.org/");
+
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/")));
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/foo")));
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/bar")));
+  Assert.ok(!ssb.canLoad(uri("http://www.mozilla.org/")));
+  Assert.ok(!ssb.canLoad(uri("https://test.mozilla.org/")));
+});
+
+add_task(async function manifest_with_scope() {
+  let ssb = await SiteSpecificBrowser.createFromManifest(
+    parseManifest("https://www.mozilla.org/foo/bar", {
+      scope: "https://www.mozilla.org/foo",
+    })
+  );
+
+  Assert.equal(ssb.startURI.spec, "https://www.mozilla.org/foo/bar");
+
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/foo")));
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/foo/bar")));
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/foo/baz")));
+
+  // Note: scopes are simple path prefixes.
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/food")));
+
+  Assert.ok(!ssb.canLoad(uri("https://www.mozilla.org/")));
+  Assert.ok(!ssb.canLoad(uri("https://www.mozilla.org/bar")));
+  Assert.ok(!ssb.canLoad(uri("http://www.mozilla.org/")));
+  Assert.ok(!ssb.canLoad(uri("https://test.mozilla.org/")));
+});
+
+add_task(async function manifest_with_start_url() {
+  let ssb = await SiteSpecificBrowser.createFromManifest(
+    parseManifest("https://www.mozilla.org/foo/bar", {
+      start_url: "https://www.mozilla.org/foo/",
+    })
+  );
+
+  Assert.equal(ssb.startURI.spec, "https://www.mozilla.org/foo/");
+
+  // scope should be "https://www.mozilla.org/foo/"
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/foo/bar")));
+  Assert.ok(ssb.canLoad(uri("https://www.mozilla.org/foo/baz")));
+
+  Assert.ok(!ssb.canLoad(uri("https://www.mozilla.org/foo")));
+  Assert.ok(!ssb.canLoad(uri("https://www.mozilla.org/")));
+  Assert.ok(!ssb.canLoad(uri("https://www.mozilla.org/bar")));
+  Assert.ok(!ssb.canLoad(uri("http://www.mozilla.org/")));
+  Assert.ok(!ssb.canLoad(uri("https://test.mozilla.org/")));
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/ssb/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+head = head.js
+firefox-appdir = browser
+
+[test_manifest.js]