Bug 1332273 - Support programmatically registered content scripts. draft
authorLuca Greco <lgreco@mozilla.com>
Fri, 03 Nov 2017 17:01:58 +0100
changeset 700596 a7ac8e403d997efb1a00909e9e5982fbc2d949ac
parent 700576 5c48b5edfc4ca945a2eaa5896454f3f4efa9052a
child 740935 80606a3e8245e01adae80cbd9e471281f338427c
push id89902
push userluca.greco@alcacoop.it
push dateMon, 20 Nov 2017 15:24:59 +0000
bugs1332273
milestone59.0a1
Bug 1332273 - Support programmatically registered content scripts. MozReview-Commit-ID: BiWlyYV7ZvB
dom/webidl/WebExtensionPolicy.webidl
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionParent.jsm
toolkit/components/extensions/WebExtensionPolicy.cpp
toolkit/components/extensions/WebExtensionPolicy.h
toolkit/components/extensions/ext-c-contentScripts.js
toolkit/components/extensions/ext-c-toolkit.js
toolkit/components/extensions/ext-contentScripts.js
toolkit/components/extensions/ext-toolkit.json
toolkit/components/extensions/extension-process-script.js
toolkit/components/extensions/jar.mn
toolkit/components/extensions/schemas/content_scripts.json
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/schemas/manifest.json
toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html
toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js
toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
--- a/dom/webidl/WebExtensionPolicy.webidl
+++ b/dom/webidl/WebExtensionPolicy.webidl
@@ -62,17 +62,17 @@ interface WebExtensionPolicy {
    * extension's optional permissions.
    */
   [Pure]
   attribute MatchPatternSet allowedOrigins;
 
   /**
    * The set of content scripts active for this extension.
    */
-  [Cached, Constant, Frozen]
+  [Cached, Frozen, Pure]
   readonly attribute sequence<WebExtensionContentScript> contentScripts;
 
   /**
    * True if the extension is currently active, false otherwise. When active,
    * the extension's moz-extension: protocol will point to the given baseURI,
    * and the set of policies for this object will be active for its ID.
    *
    * Only one extension policy with a given ID or hostname may be active at a
@@ -117,16 +117,27 @@ interface WebExtensionPolicy {
   DOMString localize(DOMString unlocalizedText);
 
   /**
    * Returns the moz-extension: URL for the given path.
    */
   [Throws]
   DOMString getURL(optional DOMString path = "");
 
+  /**
+   * Register a new content script programmatically.
+   */
+  [Throws]
+  void registerContentScript(WebExtensionContentScript script);
+
+  /**
+   * Unregister a content script.
+   */
+  [Throws]
+  void unregisterContentScript(WebExtensionContentScript script);
 
   /**
    * Returns the list of currently active extension policies.
    */
   static sequence<WebExtensionPolicy> getActiveExtensions();
 
   /**
    * Returns the currently-active policy for the extension with the given ID,
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1268,16 +1268,17 @@ this.Extension = class extends Extension
       id: this.id,
       uuid: this.uuid,
       name: this.name,
       instanceId: this.instanceId,
       manifest: this.manifest,
       resourceURL: this.resourceURL,
       baseURL: this.baseURI.spec,
       contentScripts: this.contentScripts,
+      registeredContentScripts: new Map(),
       webAccessibleResources: this.webAccessibleResources.map(res => res.glob),
       whiteListedHosts: this.whiteListedHosts.patterns.map(pat => pat.pattern),
       localeData: this.localeData.serialize(),
       permissions: this.permissions,
       principal: this.principal,
       optionalPermissions: this.manifest.optional_permissions,
     };
   }
@@ -1329,17 +1330,25 @@ this.Extension = class extends Extension
         promises.push(Management.asyncEmitManifestEntry(this, directive));
       }
     }
 
     let data = Services.ppmm.initialProcessData;
     if (!data["Extension:Extensions"]) {
       data["Extension:Extensions"] = [];
     }
+
     let serial = this.serialize();
+
+    // Map of the programmatically registered content script definitions
+    // (by string scriptId), used in ext-contentScripts.js to propagate
+    // the registered content scripts to the child content processes
+    // (e.g. when a new content process starts after a content process crash).
+    this.registeredContentScripts = serial.registeredContentScripts;
+
     data["Extension:Extensions"].push(serial);
 
     return this.broadcast("Extension:Startup", serial).then(() => {
       return Promise.all(promises);
     });
   }
 
   callOnClose(obj) {
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -478,17 +478,22 @@ defineLazyGetter(ProxyContextParent.prot
   return can;
 });
 
 defineLazyGetter(ProxyContextParent.prototype, "apiObj", function() {
   return this.apiCan.root;
 });
 
 defineLazyGetter(ProxyContextParent.prototype, "sandbox", function() {
-  return Cu.Sandbox(this.principal, {sandboxName: this.uri.spec});
+  // NOTE: the required Blob and URL globals are used in the ext-registerContentScript.js
+  // API module to convert JS and CSS data into blob URLs.
+  return Cu.Sandbox(this.principal, {
+    sandboxName: this.uri.spec,
+    wantGlobalProperties: ["Blob", "URL"],
+  });
 });
 
 /**
  * The parent side of proxied API context for extension content script
  * running in ExtensionContent.jsm.
  */
 class ContentScriptContextParent extends ProxyContextParent {
 }
--- a/toolkit/components/extensions/WebExtensionPolicy.cpp
+++ b/toolkit/components/extensions/WebExtensionPolicy.cpp
@@ -207,16 +207,49 @@ WebExtensionPolicy::GetURL(const nsAStri
   nsCOMPtr<nsIURI> uri;
   MOZ_TRY(NS_NewURI(getter_AddRefs(uri), spec));
 
   MOZ_TRY(uri->Resolve(NS_ConvertUTF16toUTF8(aPath), spec));
 
   return NS_ConvertUTF8toUTF16(spec);
 }
 
+void
+WebExtensionPolicy::RegisterContentScript(WebExtensionContentScript& script,
+                                          ErrorResult& aRv)
+{
+  // Raise an "invalid argument" error if the script is not related to
+  // the expected extension or if it is already registered.
+  if (script.mExtension != this || mContentScripts.Contains(&script)) {
+    aRv.Throw(NS_ERROR_INVALID_ARG);
+    return;
+  }
+
+  RefPtr<WebExtensionContentScript> newScript = &script;
+
+  if (!mContentScripts.AppendElement(Move(newScript), fallible)) {
+    aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+    return;
+  }
+
+  WebExtensionPolicyBinding::ClearCachedContentScriptsValue(this);
+}
+
+void
+WebExtensionPolicy::UnregisterContentScript(const WebExtensionContentScript& script,
+                                            ErrorResult& aRv)
+{
+  if (script.mExtension != this || !mContentScripts.RemoveElement(&script)) {
+    aRv.Throw(NS_ERROR_INVALID_ARG);
+    return;
+  }
+
+  WebExtensionPolicyBinding::ClearCachedContentScriptsValue(this);
+}
+
 /* static */ bool
 WebExtensionPolicy::UseRemoteWebExtensions(GlobalObject& aGlobal)
 {
   return EPS().UseRemoteExtensions();
 }
 
 /* static */ bool
 WebExtensionPolicy::IsExtensionProcess(GlobalObject& aGlobal)
--- a/toolkit/components/extensions/WebExtensionPolicy.h
+++ b/toolkit/components/extensions/WebExtensionPolicy.h
@@ -54,16 +54,22 @@ public:
   {
     MOZ_ALWAYS_SUCCEEDS(mBaseURI->GetSpec(aBaseURL));
   }
 
   void GetURL(const nsAString& aPath, nsAString& aURL, ErrorResult& aRv) const;
 
   Result<nsString, nsresult> GetURL(const nsAString& aPath) const;
 
+  void RegisterContentScript(WebExtensionContentScript& script,
+                             ErrorResult& aRv);
+
+  void UnregisterContentScript(const WebExtensionContentScript& script,
+                               ErrorResult& aRv);
+
   bool CanAccessURI(const URLInfo& aURI, bool aExplicit = false) const
   {
     return mHostPermissions && mHostPermissions->Matches(aURI, aExplicit);
   }
 
   bool IsPathWebAccessible(const nsAString& aPath) const
   {
     return mWebAccessiblePaths.Matches(aPath);
@@ -95,17 +101,16 @@ public:
   {
     return mContentSecurityPolicy;
   }
   void GetContentSecurityPolicy(nsAString& aCSP) const
   {
     aCSP = mContentSecurityPolicy;
   }
 
-
   already_AddRefed<MatchPatternSet> AllowedOrigins()
   {
     return do_AddRef(mHostPermissions);
   }
   void SetAllowedOrigins(MatchPatternSet& aAllowedOrigins)
   {
     mHostPermissions = &aAllowedOrigins;
   }
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-c-contentScripts.js
@@ -0,0 +1,69 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+var {
+  ExtensionError,
+} = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) a content script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ExtensionPageContextChild} context
+ *        The extension context which has registered the content script.
+ * @param {string} scriptId
+ *        An unique id that represents the registered content script
+ *        (generated and used internally to identify it across the different processes).
+ */
+class ContentScriptChild {
+  constructor(context, scriptId) {
+    this.context = context;
+    this.scriptId = scriptId;
+    this.unregistered = false;
+  }
+
+  async unregister() {
+    if (this.unregistered) {
+      throw new ExtensionError("Content script already unregistered");
+    }
+
+    this.unregistered = true;
+
+    await this.context.childManager.callParentAsyncFunction(
+      "contentScripts.unregister", [this.scriptId]);
+
+    this.context = null;
+  }
+
+  api() {
+    const {context} = this;
+
+    // TODO(rpl): allow to read the options related to the registered content script?
+    return {
+      unregister: () => {
+        return context.wrapPromise(this.unregister());
+      },
+    };
+  }
+}
+
+this.contentScripts = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      contentScripts: {
+        register(options) {
+          return context.cloneScope.Promise.resolve().then(async () => {
+            const scriptId = await context.childManager.callParentAsyncFunction(
+              "contentScripts.register", [options]);
+
+            const registeredScript = new ContentScriptChild(context, scriptId);
+
+            return Cu.cloneInto(registeredScript.api(), context.cloneScope,
+                                {cloneFunctions: true});
+          });
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/ext-c-toolkit.js
+++ b/toolkit/components/extensions/ext-c-toolkit.js
@@ -37,16 +37,23 @@ extensions.registerModules({
     url: "chrome://extensions/content/ext-c-backgroundPage.js",
     scopes: ["addon_child"],
     manifest: ["background"],
     paths: [
       ["extension", "getBackgroundPage"],
       ["runtime", "getBackgroundPage"],
     ],
   },
+  contentScripts: {
+    url: "chrome://extensions/content/ext-c-contentScripts.js",
+    scopes: ["addon_child"],
+    paths: [
+      ["contentScripts"],
+    ],
+  },
   extension: {
     url: "chrome://extensions/content/ext-c-extension.js",
     scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"],
     paths: [
       ["extension"],
     ],
   },
   i18n: {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-contentScripts.js
@@ -0,0 +1,194 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported registerContentScript, unregisterContentScript */
+/* global registerContentScript, unregisterContentScript */
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+var {
+  ExtensionError,
+  getUniqueId,
+} = ExtensionUtils;
+
+/**
+ * Represents (in the main browser process) a content script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ProxyContextParent} context
+ *        The parent proxy context related to the extension context which
+ *        has registered the content script.
+ * @param {RegisteredContentScriptOptions} details
+ *        The options object related to the registered content script
+ *        (which has the properties described in the content_scripts.json
+ *        JSON API schema file).
+ */
+class ContentScriptParent {
+  constructor({context, details}) {
+    this.context = context;
+    this.scriptId = getUniqueId();
+    this.blobURLs = new Set();
+
+    this.options = this._convertOptions(details);
+
+    context.callOnClose(this);
+  }
+
+  close() {
+    this.destroy();
+  }
+
+  destroy() {
+    if (this.destroyed) {
+      throw new Error("Unable to destroy ContentScriptParent twice");
+    }
+
+    this.destroyed = true;
+
+    this.context.forgetOnClose(this);
+
+    for (const blobURL of this.blobURLs) {
+      this.context.cloneScope.URL.revokeObjectURL(blobURL);
+    }
+
+    this.blobURLs.clear();
+
+    this.context = null;
+    this.options = null;
+  }
+
+  _convertOptions(details) {
+    const {context} = this;
+
+    const options = {
+      matches: details.matches,
+      exclude_matches: details.excludeMatches,
+      include_globs: details.includeGlobs,
+      exclude_globs: details.excludeGlobs,
+      all_frames: details.allFrames,
+      match_about_blank: details.matchAboutBlank,
+      run_at: details.runAt,
+      js: [],
+      css: [],
+    };
+
+    const convertCodeToURL = (data, mime) => {
+      const blob = new context.cloneScope.Blob(data, {type: mime});
+      const blobURL = context.cloneScope.URL.createObjectURL(blob);
+
+      this.blobURLs.add(blobURL);
+
+      return blobURL;
+    };
+
+    if (details.js && details.js.length > 0) {
+      options.js = details.js.map(data => {
+        if (data.file) {
+          return data.file;
+        }
+
+        return convertCodeToURL([data.code], "text/javascript");
+      });
+    }
+
+    if (details.css && details.css.length > 0) {
+      options.css = details.css.map(data => {
+        if (data.file) {
+          return data.file;
+        }
+
+        return convertCodeToURL([data.code], "text/css");
+      });
+    }
+
+    return options;
+  }
+
+  serialize() {
+    return this.options;
+  }
+}
+
+this.contentScripts = class extends ExtensionAPI {
+  getAPI(context) {
+    const {extension} = context;
+
+    // Map of the content script registered from the extension context.
+    //
+    // Map<scriptId -> ContentScriptParent>
+    const parentScriptsMap = new Map();
+
+    // Unregister all the scriptId related to a context when it is closed.
+    context.callOnClose({
+      close() {
+        if (parentScriptsMap.size === 0) {
+          return;
+        }
+
+        const scriptIds = Array.from(parentScriptsMap.keys());
+
+        for (let scriptId of scriptIds) {
+          extension.registeredContentScripts.delete(scriptId);
+        }
+
+        extension.broadcast("Extension:UnregisterContentScripts", {
+          id: extension.id,
+          scriptIds,
+        });
+      },
+    });
+
+    return {
+      contentScripts: {
+        async register(details) {
+          for (let origin of details.matches) {
+            if (!extension.whiteListedHosts.subsumes(new MatchPattern(origin))) {
+              throw new ExtensionError(`Permission denied to register a content script for ${origin}`);
+            }
+          }
+
+          const contentScript = new ContentScriptParent({context, details});
+          const {scriptId} = contentScript;
+
+          parentScriptsMap.set(scriptId, contentScript);
+
+          const scriptOptions = contentScript.serialize();
+
+          await extension.broadcast("Extension:RegisterContentScript", {
+            id: extension.id,
+            options: scriptOptions,
+            scriptId,
+          });
+
+          extension.registeredContentScripts.set(scriptId, scriptOptions);
+
+          return scriptId;
+        },
+
+        // This method is not available to the extension code, the extension code
+        // doesn't have access to the internally used scriptId, on the contrary
+        // the extension code will call script.unregister on the script API object
+        // that is resolved from the register API method returned promise.
+        async unregister(scriptId) {
+          const contentScript = parentScriptsMap.get(scriptId);
+          if (!contentScript) {
+            Cu.reportError(new Error(`No such content script ID: ${scriptId}`));
+
+            return;
+          }
+
+          parentScriptsMap.delete(scriptId);
+          extension.registeredContentScripts.delete(scriptId);
+
+          contentScript.destroy();
+
+          await extension.broadcast("Extension:UnregisterContentScripts", {
+            id: extension.id,
+            scriptIds: [scriptId],
+          });
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -27,16 +27,24 @@
   "clipboard": {
     "url": "chrome://extensions/content/ext-clipboard.js",
     "schema": "chrome://extensions/content/schemas/clipboard.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["clipboard"]
     ]
   },
+  "contentScripts": {
+    "url": "chrome://extensions/content/ext-contentScripts.js",
+    "schema": "chrome://extensions/content/schemas/content_scripts.json",
+    "scopes": ["addon_parent"],
+    "paths": [
+      ["contentScripts"]
+    ]
+  },
   "contextualIdentities": {
     "url": "chrome://extensions/content/ext-contextualIdentities.js",
     "schema": "chrome://extensions/content/schemas/contextual_identities.json",
     "scopes": ["addon_parent"],
     "events": ["startup"],
     "permissions": ["contextualIdentities"],
     "paths": [
       ["contextualIdentities"]
--- a/toolkit/components/extensions/extension-process-script.js
+++ b/toolkit/components/extensions/extension-process-script.js
@@ -272,22 +272,27 @@ DocumentManager = {
       for (let global of this.globals.keys()) {
         yield* this.enumerateWindows(global.docShell);
       }
     }
   },
 };
 
 ExtensionManager = {
+  // WeakMap<WebExtensionPolicy, Map<string, WebExtensionContentScript>>
+  registeredContentScripts: new DefaultWeakMap((extension) => new Map()),
+
   init() {
     MessageChannel.setupMessageManagers([Services.cpmm]);
 
     Services.cpmm.addMessageListener("Extension:Startup", this);
     Services.cpmm.addMessageListener("Extension:Shutdown", this);
     Services.cpmm.addMessageListener("Extension:FlushJarCache", this);
+    Services.cpmm.addMessageListener("Extension:RegisterContentScript", this);
+    Services.cpmm.addMessageListener("Extension:UnregisterContentScripts", this);
 
     let procData = Services.cpmm.initialProcessData || {};
 
     for (let data of procData["Extension:Extensions"] || []) {
       this.initExtension(data);
     }
 
     if (isContentProcess) {
@@ -332,16 +337,30 @@ ExtensionManager = {
         localizeCallback,
 
         backgroundScripts: (extension.manifest.background &&
                             extension.manifest.background.scripts),
 
         contentScripts: extension.contentScripts.map(parseScriptOptions),
       });
 
+      // Register any existent dinamically registered content script for the extension
+      // when a content process is started for the first time (which also cover
+      // a content process that crashed and it has been recreated).
+      const registeredContentScripts = this.registeredContentScripts.get(policy);
+
+      if (extension.registeredContentScripts) {
+        for (let [scriptId, options] of extension.registeredContentScripts) {
+          const parsedOptions = parseScriptOptions(options);
+          const script = new WebExtensionContentScript(policy, parsedOptions);
+          policy.registerContentScript(script);
+          registeredContentScripts.set(scriptId, script);
+        }
+      }
+
       policy.active = true;
       policy.initData = extension;
     }
     return policy;
   },
 
   initExtension(data) {
     let policy = this.initExtensionPolicy(data);
@@ -391,16 +410,64 @@ ExtensionManager = {
             this.schemaJSON;
         }
 
         for (let [url, schema] of data) {
           this.schemaJSON.set(url, schema);
         }
         break;
       }
+
+      case "Extension:RegisterContentScript": {
+        let policy = WebExtensionPolicy.getByID(data.id);
+
+        if (policy) {
+          const registeredContentScripts = this.registeredContentScripts.get(policy);
+
+          if (registeredContentScripts.has(data.scriptId)) {
+            Cu.reportError(new Error(
+              `Registering content script ${data.scriptId} on ${data.id} more than once`));
+          } else {
+            try {
+              const parsedOptions = parseScriptOptions(data.options);
+              const script = new WebExtensionContentScript(policy, parsedOptions);
+              policy.registerContentScript(script);
+              registeredContentScripts.set(data.scriptId, script);
+            } catch (e) {
+              Cu.reportError(e);
+            }
+          }
+        }
+
+        Services.cpmm.sendAsyncMessage("Extension:RegisterContentScriptComplete");
+        break;
+      }
+
+      case "Extension:UnregisterContentScripts": {
+        let policy = WebExtensionPolicy.getByID(data.id);
+
+        if (policy) {
+          const registeredContentScripts = this.registeredContentScripts.get(policy);
+
+          for (const scriptId of data.scriptIds) {
+            const script = registeredContentScripts.get(scriptId);
+            if (script) {
+              try {
+                policy.unregisterContentScript(script);
+                registeredContentScripts.delete(scriptId);
+              } catch (e) {
+                Cu.reportError(e);
+              }
+            }
+          }
+        }
+
+        Services.cpmm.sendAsyncMessage("Extension:UnregisterContentScriptsComplete");
+        break;
+      }
     }
   },
 };
 
 function ExtensionProcessScript() {
   if (!ExtensionProcessScript.singleton) {
     ExtensionProcessScript.singleton = this;
   }
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -4,16 +4,17 @@
 
 toolkit.jar:
 % content extensions %content/extensions/
     content/extensions/dummy.xul
     content/extensions/ext-alarms.js
     content/extensions/ext-backgroundPage.js
     content/extensions/ext-browser-content.js
     content/extensions/ext-browserSettings.js
+    content/extensions/ext-contentScripts.js
     content/extensions/ext-contextualIdentities.js
     content/extensions/ext-clipboard.js
     content/extensions/ext-cookies.js
     content/extensions/ext-downloads.js
     content/extensions/ext-extension.js
     content/extensions/ext-i18n.js
 #ifndef ANDROID
     content/extensions/ext-identity.js
@@ -32,16 +33,17 @@ toolkit.jar:
     content/extensions/ext-toolkit.js
     content/extensions/ext-toolkit.json
     content/extensions/ext-topSites.js
     content/extensions/ext-webRequest.js
     content/extensions/ext-webNavigation.js
     # Below is a separate group using the naming convention ext-c-*.js that run
     # in the child process.
     content/extensions/ext-c-backgroundPage.js
+    content/extensions/ext-c-contentScripts.js
     content/extensions/ext-c-extension.js
 #ifndef ANDROID
     content/extensions/ext-c-identity.js
 #endif
     content/extensions/ext-c-runtime.js
     content/extensions/ext-c-storage.js
     content/extensions/ext-c-test.js
     content/extensions/ext-c-toolkit.js
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/content_scripts.json
@@ -0,0 +1,109 @@
+/* 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/. */
+
+[
+  {
+    "namespace": "contentScripts",
+    "types": [
+      {
+        "id": "ExtensionFileOrCode",
+        "choices": [
+          {
+            "type": "object",
+            "properties": {
+              "file": {
+                "$ref": "manifest.ExtensionURL"
+              }
+            }
+          },
+          {
+            "type": "object",
+            "properties": {
+              "code": {
+                "type": "string"
+              }
+            }
+          }
+        ]
+      },
+      {
+        "id": "RegisteredContentScriptOptions",
+        "type": "object",
+        "description": "Details of a content script registered programmatically",
+        "additionalProperties": { "$ref": "UnrecognizedProperty" },
+        "properties": {
+          "matches": {
+            "type": "array",
+            "optional": false,
+            "minItems": 1,
+            "items": { "$ref": "manifest.MatchPattern" }
+          },
+          "excludeMatches": {
+            "type": "array",
+            "optional": true,
+            "minItems": 1,
+            "items": { "$ref": "manifest.MatchPattern" }
+          },
+          "includeGlobs": {
+            "type": "array",
+            "optional": true,
+            "items": { "type": "string" }
+          },
+          "excludeGlobs": {
+            "type": "array",
+            "optional": true,
+            "items": { "type": "string" }
+          },
+          "css": {
+            "type": "array",
+            "optional": true,
+            "description": "The list of CSS files to inject",
+            "items": { "$ref": "ExtensionFileOrCode" }
+          },
+          "js": {
+            "type": "array",
+            "optional": true,
+            "description": "The list of JS files to inject",
+            "items": { "$ref": "ExtensionFileOrCode" }
+          },
+          "allFrames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."},
+          "matchAboutBlank": {"type": "boolean", "optional": true, "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."},
+          "runAt": {
+            "$ref": "extensionTypes.RunAt",
+            "optional": true,
+            "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"."
+          }
+        }
+      },
+      {
+        "id": "RegisteredContentScript",
+        "type": "object",
+        "description": "An object that represents a content script registered programmatically",
+        "functions": [
+          {
+            "name": "unregister",
+            "type": "function",
+            "description": "Unregister a content script registered programmatically",
+            "async": true,
+            "parameters": []
+          }
+        ]
+      }
+    ],
+    "functions": [
+      {
+        "name": "register",
+        "type": "function",
+        "description": "Register a content script programmatically",
+        "async": true,
+        "parameters": [
+          {
+            "name": "contentScriptOptions",
+            "$ref": "RegisteredContentScriptOptions"
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -2,16 +2,17 @@
 # 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/.
 
 toolkit.jar:
 % content extensions %content/extensions/
     content/extensions/schemas/alarms.json
     content/extensions/schemas/browser_settings.json
     content/extensions/schemas/clipboard.json
+    content/extensions/schemas/content_scripts.json
     content/extensions/schemas/contextual_identities.json
     content/extensions/schemas/cookies.json
     content/extensions/schemas/downloads.json
     content/extensions/schemas/events.json
     content/extensions/schemas/experiments.json
     content/extensions/schemas/extension.json
     content/extensions/schemas/extension_types.json
     content/extensions/schemas/extension_protocol_handlers.json
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -533,17 +533,17 @@
             "type": "array",
             "optional": true,
             "description": "The list of CSS files to inject",
             "items": { "$ref": "ExtensionURL" }
           },
           "js": {
             "type": "array",
             "optional": true,
-            "description": "The list of CSS files to inject",
+            "description": "The list of JS files to inject",
             "items": { "$ref": "ExtensionURL" }
           },
           "all_frames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."},
           "match_about_blank": {"type": "boolean", "optional": true, "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."},
           "run_at": {
             "$ref": "extensionTypes.RunAt",
             "optional": true,
             "default": "document_idle",
--- a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
+++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
@@ -54,16 +54,17 @@ let expectedCommonApis = [
 let expectedContentApis = [
   ...expectedCommonApis,
   ...expectedContentApisTargetSpecific,
 ];
 
 let expectedBackgroundApis = [
   ...expectedCommonApis,
   ...expectedBackgroundApisTargetSpecific,
+  "contentScripts.register",
   "extension.ViewType",
   "extension.getBackgroundPage",
   "extension.getViews",
   "extension.isAllowedFileSchemeAccess",
   "extension.isAllowedIncognitoAccess",
   // Note: extensionTypes is not visible in Chrome.
   "extensionTypes.CSSOrigin",
   "extensionTypes.ImageFormat",
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="registered-extension-url-style">Registered Extension URL style</div>
+<div id="registered-extension-text-style">Registered Extension Text style</div>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
+++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
@@ -1,15 +1,15 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 const {newURI} = Services.io;
 
-add_task(async function test_WebExtensinonPolicy() {
+add_task(async function test_WebExtensionPolicy() {
   const id = "foo@bar.baz";
   const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610";
 
   const baseURL = "file:///foo/";
   const mozExtURL = `moz-extension://${uuid}/`;
   const mozExtURI = newURI(mozExtURL);
 
   let policy = new WebExtensionPolicy({
@@ -133,8 +133,105 @@ add_task(async function test_WebExtensin
 
       Assert.throws(() => { policy2.active = true; }, /NS_ERROR_UNEXPECTED/,
                     `Should not be able to activate conflicting policy: ${id} ${uuid}`);
     }
 
     policy.active = false;
   }
 });
+
+add_task(async function test_WebExtensionPolicy_registerContentScripts() {
+  const id = "foo@bar.baz";
+  const uuid = "77a7b9d3-e73c-4cf3-97fb-1824868fe00f";
+
+  const id2 = "foo-2@bar.baz";
+  const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd";
+
+  const baseURL = "file:///foo/";
+
+  const mozExtURL = `moz-extension://${uuid}/`;
+  const mozExtURL2 = `moz-extension://${uuid2}/`;
+
+  let policy = new WebExtensionPolicy({
+    id,
+    mozExtensionHostname: uuid,
+    baseURL,
+    localizeCallback() {},
+    allowedOrigins: new MatchPatternSet([]),
+    permissions: ["<all_urls>"],
+  });
+
+  let policy2 = new WebExtensionPolicy({
+    id: id2,
+    mozExtensionHostname: uuid2,
+    baseURL,
+    localizeCallback() {},
+    allowedOrigins: new MatchPatternSet([]),
+    permissions: ["<all_urls>"],
+  });
+
+  let script1 = new WebExtensionContentScript(policy, {
+    run_at: "document_end",
+    js: [`${mozExtURL}/registered-content-script.js`],
+    matches: new MatchPatternSet(["http://localhost/data/*"]),
+  });
+
+  let script2 = new WebExtensionContentScript(policy, {
+    run_at: "document_end",
+    css: [`${mozExtURL}/registered-content-style.css`],
+    matches: new MatchPatternSet(["http://localhost/data/*"]),
+  });
+
+  let script3 = new WebExtensionContentScript(policy2, {
+    run_at: "document_end",
+    css: [`${mozExtURL2}/registered-content-style.css`],
+    matches: new MatchPatternSet(["http://localhost/data/*"]),
+  });
+
+  deepEqual(policy.contentScripts, [], "The policy contentScripts is initially empty");
+
+  policy.registerContentScript(script1);
+
+  deepEqual(policy.contentScripts, [script1],
+            "script1 has been added to the policy contentScripts");
+
+  Assert.throws(
+    () => policy.registerContentScript(script1),
+    (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+    "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script more than once");
+
+  Assert.throws(
+    () => policy.registerContentScript(script3),
+    (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+    "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script related to " +
+    "a different extension");
+
+  Assert.throws(
+    () => policy.unregisterContentScript(script3),
+    (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+    "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script related to " +
+    "a different extension");
+
+  deepEqual(policy.contentScripts, [script1], "script1 has not been added twice");
+
+  policy.registerContentScript(script2);
+
+  deepEqual(policy.contentScripts, [script1, script2],
+            "script2 has the last item of the policy contentScripts array");
+
+  policy.unregisterContentScript(script1);
+
+  deepEqual(policy.contentScripts, [script2],
+            "script1 has been removed from the policy contentscripts");
+
+  Assert.throws(
+    () => policy.unregisterContentScript(script1),
+    (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+    "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script more than once");
+
+  deepEqual(policy.contentScripts, [script2],
+            "the policy contentscripts is unmodified when unregistering an unknown contentScript");
+
+  policy.unregisterContentScript(script2);
+
+  deepEqual(policy.contentScripts, [], "script2 has been removed from the policy contentScripts");
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js
@@ -0,0 +1,481 @@
+"use strict";
+
+const {
+  createAppInfo,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+function check_applied_styles() {
+  const urlElStyle = getComputedStyle(document.querySelector("#registered-extension-url-style"));
+  const blobElStyle = getComputedStyle(document.querySelector("#registered-extension-text-style"));
+
+  browser.test.sendMessage("registered-styles-results", {
+    registeredExtensionUrlStyleBG: urlElStyle["background-color"],
+    registeredExtensionBlobStyleBG: blobElStyle["background-color"],
+  });
+}
+
+add_task(async function test_contentscripts_register_css() {
+  async function background() {
+    let cssCode = `
+      #registered-extension-text-style {
+        background-color: blue;
+      }
+    `;
+
+    let fileScript = await browser.contentScripts.register({
+      css: [{file: "registered_ext_style.css"}],
+      matches: ["http://localhost/*/file_sample_registered_styles.html"],
+      runAt: "document_start",
+    });
+
+    let textScript = await browser.contentScripts.register({
+      css: [{code: cssCode}],
+      matches: ["http://localhost/*/file_sample_registered_styles.html"],
+      runAt: "document_start",
+    });
+
+    browser.test.onMessage.addListener(async (msg) => {
+      switch (msg) {
+        case "unregister-text":
+          await textScript.unregister().catch(err => {
+            browser.test.fail(`Unexpected exception while unregistering text style: ${err}`);
+          });
+
+          await browser.test.assertRejects(
+            textScript.unregister(),
+            /Content script already unregistered/,
+            "Got the expected rejection on calling script.unregister() multiple times"
+          );
+
+          browser.test.sendMessage("unregister-text:done");
+          break;
+        case "unregister-file":
+          await fileScript.unregister().catch(err => {
+            browser.test.fail(`Unexpected exception while unregistering url style: ${err}`);
+          });
+
+          await browser.test.assertRejects(
+            fileScript.unregister(),
+            /Content script already unregistered/,
+            "Got the expected rejection on calling script.unregister() multiple times"
+          );
+
+          browser.test.sendMessage("unregister-file:done");
+          break;
+        default:
+          browser.test.fail(`Unexpected test message received: ${msg}`);
+      }
+    });
+
+    browser.test.sendMessage("background_ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: [
+        "http://localhost/*/file_sample_registered_styles.html",
+        "<all_urls>",
+      ],
+      content_scripts: [
+        {
+          matches: ["http://localhost/*/file_sample_registered_styles.html"],
+          run_at: "document_idle",
+          js: ["check_applied_styles.js"],
+        },
+      ],
+    },
+    background,
+
+    files: {
+      "check_applied_styles.js": check_applied_styles,
+      "registered_ext_style.css": `
+        #registered-extension-url-style {
+          background-color: red;
+        }
+      `,
+    },
+  });
+
+  await extension.startup();
+
+  await extension.awaitMessage("background_ready");
+
+  // Ensure that a content page running in a content process and which has been
+  // started after the content scripts has been registered, it still receives
+  // and registers the expected content scripts.
+  let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+  await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+  const registeredStylesResults = await extension.awaitMessage("registered-styles-results");
+
+  equal(registeredStylesResults.registeredExtensionUrlStyleBG, "rgb(255, 0, 0)",
+        "The expected style has been applied from the registered extension url style");
+  equal(registeredStylesResults.registeredExtensionBlobStyleBG, "rgb(0, 0, 255)",
+        "The expected style has been applied from the registered extension blob style");
+
+  extension.sendMessage("unregister-file");
+  await extension.awaitMessage("unregister-file:done");
+
+  contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+  const unregisteredURLStylesResults = await extension.awaitMessage("registered-styles-results");
+
+  equal(unregisteredURLStylesResults.registeredExtensionUrlStyleBG, "rgba(0, 0, 0, 0)",
+        "The expected style has been applied once extension url style has been unregistered");
+  equal(unregisteredURLStylesResults.registeredExtensionBlobStyleBG, "rgb(0, 0, 255)",
+        "The expected style has been applied from the registered extension blob style");
+
+  extension.sendMessage("unregister-text");
+  await extension.awaitMessage("unregister-text:done");
+
+  contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+  const unregisteredBlobStylesResults = await extension.awaitMessage("registered-styles-results");
+
+  equal(unregisteredBlobStylesResults.registeredExtensionUrlStyleBG, "rgba(0, 0, 0, 0)",
+        "The expected style has been applied once extension url style has been unregistered");
+  equal(unregisteredBlobStylesResults.registeredExtensionBlobStyleBG, "rgba(0, 0, 0, 0)",
+        "The expected style has been applied once extension blob style has been unregistered");
+
+  await contentPage.close();
+  await extension.unload();
+});
+
+add_task(async function test_contentscripts_unregister_on_context_unload() {
+  async function background() {
+    const frame = document.createElement("iframe");
+    frame.setAttribute("src", "/background-frame.html");
+
+    document.body.appendChild(frame);
+
+    browser.test.onMessage.addListener((msg) => {
+      switch (msg) {
+        case "unload-frame":
+          frame.remove();
+          browser.test.sendMessage("unload-frame:done");
+          break;
+        default:
+          browser.test.fail(`Unexpected test message received: ${msg}`);
+      }
+    });
+
+    browser.test.sendMessage("background_ready");
+  }
+
+  async function background_frame() {
+    await browser.contentScripts.register({
+      css: [{file: "registered_ext_style.css"}],
+      matches: ["http://localhost/*/file_sample_registered_styles.html"],
+      runAt: "document_start",
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: [
+        "http://localhost/*/file_sample_registered_styles.html",
+      ],
+      content_scripts: [
+        {
+          matches: ["http://localhost/*/file_sample_registered_styles.html"],
+          run_at: "document_idle",
+          js: ["check_applied_styles.js"],
+        },
+      ],
+    },
+    background,
+
+    files: {
+      "background-frame.html": `<!DOCTYPE html>
+        <html>
+          <head>
+            <script src="background-frame.js"></script>
+          </head>
+          <body>
+          </body>
+        </html>
+      `,
+      "background-frame.js": background_frame,
+      "check_applied_styles.js": check_applied_styles,
+      "registered_ext_style.css": `
+        #registered-extension-url-style {
+          background-color: red;
+        }
+      `,
+    },
+  });
+
+  await extension.startup();
+
+  await extension.awaitMessage("background_ready");
+
+  // Ensure that a content page running in a content process and which has been
+  // started after the content scripts has been registered, it still receives
+  // and registers the expected content scripts.
+  let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+  await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+  const registeredStylesResults = await extension.awaitMessage("registered-styles-results");
+
+  equal(registeredStylesResults.registeredExtensionUrlStyleBG, "rgb(255, 0, 0)",
+        "The expected style has been applied from the registered extension url style");
+
+  extension.sendMessage("unload-frame");
+  await extension.awaitMessage("unload-frame:done");
+
+  await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+  const unregisteredURLStylesResults = await extension.awaitMessage("registered-styles-results");
+
+  equal(unregisteredURLStylesResults.registeredExtensionUrlStyleBG, "rgba(0, 0, 0, 0)",
+        "The expected style has been applied once extension url style has been unregistered");
+
+  await contentPage.close();
+  await extension.unload();
+});
+
+add_task(async function test_contentscripts_register_js() {
+  async function background() {
+    browser.runtime.onMessage.addListener(([msg, expectedStates, readyState], sender) => {
+      if (msg == "chrome-namespace-ok") {
+        browser.test.sendMessage(msg);
+        return;
+      }
+
+      browser.test.assertEq("script-run", msg, "message type is correct");
+      browser.test.assertTrue(expectedStates.includes(readyState),
+                              `readyState "${readyState}" is one of [${expectedStates}]`);
+      browser.test.sendMessage("script-run-" + expectedStates[0]);
+    });
+
+    // Raise an exception when the content script cannot be registered
+    // because the extension has no permission to access the specified origin.
+
+    await browser.test.assertRejects(
+      browser.contentScripts.register({
+        matches: ["http://*/*"],
+        js: [{code: "browser.test.fail(\"content script with wrong matches should not run\")"}],
+      }),
+      /Permission denied to register a content script for/,
+      "The reject contains the expected error message"
+    );
+
+    // Register a content script from a JS code string.
+
+    function textScriptCodeStart() {
+      browser.runtime.sendMessage(["script-run", ["loading"], document.readyState]);
+    }
+    function textScriptCodeEnd() {
+      browser.runtime.sendMessage(["script-run", ["interactive", "complete"], document.readyState]);
+    }
+    function textScriptCodeIdle() {
+      browser.runtime.sendMessage(["script-run", ["complete"], document.readyState]);
+    }
+
+    // Register content scripts from both extension URLs and plain JS code strings.
+
+    const content_scripts = [
+      // Plain JS code strings.
+      {
+        matches: ["http://localhost/*/file_sample.html"],
+        js: [{code: `(${textScriptCodeStart})()`}],
+        runAt: "document_start",
+      },
+      {
+        matches: ["http://localhost/*/file_sample.html"],
+        js: [{code: `(${textScriptCodeEnd})()`}],
+        runAt: "document_end",
+      },
+      {
+        matches: ["http://localhost/*/file_sample.html"],
+        js: [{code: `(${textScriptCodeIdle})()`}],
+        runAt: "document_idle",
+      },
+
+      // Extension URLs.
+      {
+        matches: ["http://localhost/*/file_sample.html"],
+        js: [{file: "content_script_start.js"}],
+        runAt: "document_start",
+      },
+      {
+        matches: ["http://localhost/*/file_sample.html"],
+        js: [{file: "content_script_end.js"}],
+        runAt: "document_end",
+      },
+      {
+        matches: ["http://localhost/*/file_sample.html"],
+        js: [{file: "content_script_idle.js"}],
+        runAt: "document_idle",
+      },
+      {
+        matches: ["http://localhost/*/file_sample.html"],
+        js: [{file: "content_script.js"}],
+        runAt: "document_idle",
+      },
+    ];
+
+    const expectedAPIs = ["unregister"];
+
+    for (const scriptOptions of content_scripts) {
+      const script = await browser.contentScripts.register(scriptOptions);
+      const actualAPIs = Object.keys(script);
+
+      browser.test.assertEq(JSON.stringify(expectedAPIs),
+                            JSON.stringify(actualAPIs),
+                            `Got a script API object for ${scriptOptions.js[0]}`);
+    }
+
+    browser.test.sendMessage("background-ready");
+  }
+
+  function contentScriptStart() {
+    browser.runtime.sendMessage(["script-run", ["loading"], document.readyState]);
+  }
+  function contentScriptEnd() {
+    browser.runtime.sendMessage(["script-run", ["interactive", "complete"], document.readyState]);
+  }
+  function contentScriptIdle() {
+    browser.runtime.sendMessage(["script-run", ["complete"], document.readyState]);
+  }
+
+  function contentScript() {
+    let manifest = browser.runtime.getManifest();
+    void manifest.permissions;
+    browser.runtime.sendMessage(["chrome-namespace-ok"]);
+  }
+
+  let extensionData = {
+    manifest: {
+      permissions: [
+        "http://localhost/*/file_sample.html",
+      ],
+    },
+    background,
+
+    files: {
+      "content_script_start.js": contentScriptStart,
+      "content_script_end.js": contentScriptEnd,
+      "content_script_idle.js": contentScriptIdle,
+      "content_script.js": contentScript,
+    },
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+  let loadingCount = 0;
+  let interactiveCount = 0;
+  let completeCount = 0;
+  extension.onMessage("script-run-loading", () => { loadingCount++; });
+  extension.onMessage("script-run-interactive", () => { interactiveCount++; });
+
+  let completePromise = new Promise(resolve => {
+    extension.onMessage("script-run-complete", () => { completeCount++; resolve(); });
+  });
+
+  let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok");
+
+  // Ensure that a content page running in a content process and which has been
+  // already loaded when the content scripts has been registered, it has received
+  // and registered the expected content scripts.
+  let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+  await extension.startup();
+  await extension.awaitMessage("background-ready");
+
+  contentPage.loadURL(`${BASE_URL}/file_sample.html`);
+
+  await Promise.all([completePromise, chromeNamespacePromise]);
+
+  await contentPage.close();
+
+  // Expect two content scripts to run (one registered using an extension URL
+  // and one registered from plain JS code).
+  equal(loadingCount, 2, "document_start script ran exactly twice");
+  equal(interactiveCount, 2, "document_end script ran exactly twice");
+  equal(completeCount, 2, "document_idle script ran exactly twice");
+
+  await extension.unload();
+});
+
+// Test that the contentScript.register options are correctly translated
+// into the expected WebExtensionContentScript properties.
+add_task(async function test_contentscripts_register_all_options() {
+  async function background() {
+    await browser.contentScripts.register({
+      js: [{file: "content_script.js"}],
+      css: [{file: "content_style.css"}],
+      matches: ["http://localhost/*"],
+      excludeMatches: ["http://localhost/exclude/*"],
+      excludeGlobs: ["*_exclude.html"],
+      includeGlobs: ["*_include.html"],
+      allFrames: true,
+      matchAboutBlank: true,
+      runAt: "document_start",
+    });
+
+    browser.test.sendMessage("background-ready", window.location.origin);
+  }
+
+  const extensionData = {
+    manifest: {
+      permissions: [
+        "http://localhost/*",
+      ],
+    },
+    background,
+
+    files: {
+      "content_script.js": "",
+      "content_style.css": "",
+    },
+  };
+
+  const extension = ExtensionTestUtils.loadExtension(extensionData);
+
+  await extension.startup();
+
+  const baseExtURL = await extension.awaitMessage("background-ready");
+
+  const policy = WebExtensionPolicy.getByID(extension.id);
+
+  ok(policy, "Got the WebExtensionPolicy for the test extension");
+  equal(policy.contentScripts.length, 1, "Got the expected number of registered content scripts");
+
+  const script = policy.contentScripts[0];
+  let {allFrames, cssPaths, jsPaths, matchAboutBlank, runAt} = script;
+
+  deepEqual({
+    allFrames,
+    cssPaths,
+    jsPaths,
+    matchAboutBlank,
+    runAt,
+  }, {
+    allFrames: true,
+    cssPaths: [`${baseExtURL}/content_style.css`],
+    jsPaths: [`${baseExtURL}/content_script.js`],
+    matchAboutBlank: true,
+    runAt: "document_start",
+  }, "Got the expected content script properties");
+
+  ok(script.matchesURI(Services.io.newURI("http://localhost/ok_include.html")),
+     "matched and include globs should match");
+  ok(!script.matchesURI(Services.io.newURI("http://localhost/exclude/ok_include.html")),
+     "exclude matches should not match");
+  ok(!script.matchesURI(Services.io.newURI("http://localhost/ok_exclude.html")),
+     "exclude globs should not match");
+
+  await extension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
@@ -2,9 +2,10 @@
 skip-if = os == "android" || (os == "win" && debug)
 [test_ext_i18n_css.js]
 [test_ext_contentscript.js]
 [test_ext_contentscript_scriptCreated.js]
 skip-if = debug # Bug 1407501
 [test_ext_contentscript_triggeringPrincipal.js]
 skip-if = os == "android" && debug
 [test_ext_contentscript_xrays.js]
+[test_ext_contentScripts_register.js]
 [test_ext_adoption_with_xrays.js]