Bug 1220811: Add code coverage tests for WebExtensions. draft
authorKris Maglione <maglione.k@gmail.com>
Sat, 20 Feb 2016 21:28:48 -0800
changeset 392112 5cb66a0f8b59584f11e66693c1b04e9ddd4e0b65
parent 392111 59016b22b50fd7b3c26f1c1e77641d739bb1be0e
child 526253 218529d7412be60005de38791dd09861392e6f67
push id23941
push userbmo:amckay@mozilla.com
push dateSat, 23 Jul 2016 02:14:46 +0000
bugs1220811
milestone50.0a1
Bug 1220811: Add code coverage tests for WebExtensions. MozReview-Commit-ID: 7Fo5lowNIin
browser/components/extensions/ext-browserAction.js
browser/components/extensions/ext-contextMenus.js
browser/components/extensions/ext-tabs.js
browser/components/extensions/ext-utils.js
browser/components/extensions/test/xpcshell/head.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ExtensionManagement.jsm
toolkit/components/extensions/ExtensionStorage.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/MessageChannel.jsm
toolkit/components/extensions/NativeMessaging.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/ext-backgroundPage.js
toolkit/components/extensions/ext-cookies.js
toolkit/components/extensions/ext-test.js
toolkit/components/extensions/instrument_code.py
toolkit/components/extensions/test/xpcshell/head.js
toolkit/components/extensions/test_coverage.sh
toolkit/components/utils/simpleServices.js
toolkit/modules/addons/MatchPattern.jsm
toolkit/modules/addons/WebExtCoverage.jsm
toolkit/modules/addons/WebNavigation.jsm
toolkit/modules/addons/WebNavigationContent.js
toolkit/modules/addons/WebNavigationFrames.jsm
toolkit/modules/addons/WebRequest.jsm
toolkit/modules/addons/WebRequestCommon.jsm
toolkit/modules/addons/WebRequestContent.js
toolkit/modules/moz.build
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
--- a/browser/components/extensions/ext-browserAction.js
+++ b/browser/components/extensions/ext-browserAction.js
@@ -67,16 +67,17 @@ BrowserAction.prototype = {
         view.id = this.viewId;
         view.setAttribute("flex", "1");
 
         document.getElementById("PanelUI-multiView").appendChild(view);
       },
 
       onDestroyed: document => {
         let view = document.getElementById(this.viewId);
+        /* istanbul ignore else */
         if (view) {
           view.remove();
         }
       },
 
       onCreated: node => {
         node.classList.add("badged-button");
         node.classList.add("webextension-browser-action");
@@ -96,17 +97,19 @@ BrowserAction.prototype = {
         // If the widget has a popup URL defined, we open a popup, but do not
         // dispatch a click event to the extension.
         // If it has no popup URL defined, we dispatch a click event, but do not
         // open a popup.
         if (popupURL) {
           try {
             new ViewPopup(this.extension, event.target, popupURL, this.browserStyle);
           } catch (e) {
+            /* istanbul ignore next */
             Cu.reportError(e);
+            /* istanbul ignore next */
             event.preventDefault();
           }
         } else {
           // This isn't not a hack, but it seems to provide the correct behavior
           // with the fewest complications.
           event.preventDefault();
           this.emit("click");
         }
@@ -179,16 +182,17 @@ BrowserAction.prototype = {
       --webextension-toolbar-image: url("${escape(icon)}");
       --webextension-toolbar-image-2x: url("${getIcon(baseSize * 2)}");
     `);
   },
 
   // Update the toolbar button for a given window.
   updateWindow(window) {
     let widget = this.widget.forWindow(window);
+    /* istanbul ignore else */
     if (widget) {
       let tab = window.gBrowser.selectedTab;
       this.updateButton(widget.node, this.tabContext.get(tab));
     }
   },
 
   // Update the toolbar button when the extension changes the icon,
   // title, badge, etc. If it only changes a parameter for a single
--- a/browser/components/extensions/ext-contextMenus.js
+++ b/browser/components/extensions/ext-contextMenus.js
@@ -346,29 +346,31 @@ MenuItem.prototype = {
       this.root.addChild(this);
     } else {
       let menuMap = gContextMenuMap.get(this.extension);
       menuMap.get(parentId).addChild(this);
     }
   },
 
   get parentId() {
-    return this.parent ? this.parent.id : undefined;
+    return this.parent ? this.parent.id : /* istanbul ignore next */ undefined;
   },
 
   addChild(child) {
+    /* istanbul ignore if */
     if (child.parent) {
       throw new Error("Child MenuItem already has a parent.");
     }
     this.children.push(child);
     child.parent = this;
   },
 
   detachChild(child) {
     let idx = this.children.indexOf(child);
+    /* istanbul ignore if */
     if (idx < 0) {
       throw new Error("Child MenuItem not found, it cannot be removed.");
     }
     this.children.splice(idx, 1);
     child.parent = null;
   },
 
   get root() {
@@ -501,16 +503,17 @@ extensions.registerSchemaAPI("contextMen
         if (menuItem) {
           menuItem.remove();
         }
         return Promise.resolve();
       },
 
       removeAll: function() {
         let root = gRootItems.get(extension);
+        /* istanbul ignore else */
         if (root) {
           root.remove();
         }
         return Promise.resolve();
       },
 
       onClicked: new EventManager(context, "contextMenus.onClicked", fire => {
         let listener = (event, info, tab) => {
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -22,16 +22,17 @@ var {
 // This function is pretty tightly tied to Extension.jsm.
 // Its job is to fill in the |tab| property of the sender.
 function getSender(context, target, sender) {
   // The message was sent from a content script to a <browser> element.
   // We can just get the |tab| from |target|.
   if (target instanceof Ci.nsIDOMXULElement) {
     // The message came from a content script.
     let tabbrowser = target.ownerGlobal.gBrowser;
+    /* istanbul ignore if */
     if (!tabbrowser) {
       return;
     }
     let tab = tabbrowser.getTabForBrowser(target);
 
     sender.tab = TabManager.convert(context.extension, tab);
   } else if ("tabId" in sender) {
     // The message came from an ExtensionContext. In that case, it should
@@ -480,16 +481,17 @@ extensions.registerSchemaAPI("tabs", (ex
         };
       }).api(),
 
       create: function(createProperties) {
         return new Promise((resolve, reject) => {
           let window = createProperties.windowId !== null ?
             WindowManager.getWindow(createProperties.windowId, context) :
             WindowManager.topWindow;
+          /* istanbul ignore if */
           if (!window.gBrowser) {
             let obs = (finishedWindow, topic, data) => {
               if (finishedWindow != window) {
                 return;
               }
               Services.obs.removeObserver(obs, "browser-delayed-startup-finished");
               resolve(window);
             };
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -98,16 +98,17 @@ class BasePopup {
       this.browser = null;
       this.viewNode = null;
     });
   }
 
   // Returns the name of the event fired on `viewNode` when the popup is being
   // destroyed. This must be implemented by every subclass.
   get DESTROY_EVENT() {
+    /* istanbul ignore next */
     throw new Error("Not implemented");
   }
 
   handleEvent(event) {
     switch (event.type) {
       case this.DESTROY_EVENT:
         this.destroy();
         break;
@@ -226,16 +227,17 @@ class BasePopup {
       height = h.value / this.window.devicePixelRatio;
 
       // The width calculation is imperfect, and is often a fraction of a pixel
       // too narrow, even after taking the ceiling, which causes lines of text
       // to wrap.
       width += 1;
     } catch (e) {
       // getContentSize can throw
+      /* istanbul ignore next */
       [width, height] = [400, 400];
     }
 
     width = Math.ceil(Math.min(width, 800));
     height = Math.ceil(Math.min(height, 600));
 
     this.browser.style.width = `${width}px`;
     this.browser.style.height = `${height}px`;
@@ -315,16 +317,17 @@ TabContext.prototype = {
     return this.tabData.get(tab);
   },
 
   clear(tab) {
     this.tabData.delete(tab);
   },
 
   handleEvent(event) {
+    /* istanbul ignore else */
     if (event.type == "TabSelect") {
       let tab = event.target;
       this.emit("tab-select", tab);
       this.emit("location-change", tab);
     }
   },
 
   onLocationChange(browser, webProgress, request, locationURI, flags) {
@@ -455,16 +458,17 @@ global.TabManager = {
   handleEvent(event) {
     if (event.type == "TabOpen") {
       let {adoptedTab} = event.detail;
       if (adoptedTab) {
         // This tab is being created to adopt a tab from a different window.
         // Copy the ID from the old tab to the new.
         this._tabs.set(event.target, this.getId(adoptedTab));
       }
+    /* istanbul ignore else */
     } else if (event.type == "TabClose") {
       let {adoptedBy} = event.detail;
       if (adoptedBy) {
         // This tab is being closed because it was adopted by a new window.
         // Copy its ID to the new tab, in case it was created as the first tab
         // of a new window, and did not have an `adoptedTab` detail when it was
         // opened.
         this._tabs.set(adoptedBy, this.getId(event.target));
@@ -493,45 +497,49 @@ global.TabManager = {
     this._tabs.set(tab, id);
     return id;
   },
 
   getBrowserId(browser) {
     let gBrowser = browser.ownerGlobal.gBrowser;
     // Some non-browser windows have gBrowser but not
     // getTabForBrowser!
+    /* istanbul ignore else */
     if (gBrowser && gBrowser.getTabForBrowser) {
       let tab = gBrowser.getTabForBrowser(browser);
       if (tab) {
         return this.getId(tab);
       }
     }
     return -1;
   },
 
   getTab(tabId) {
     // FIXME: Speed this up without leaking memory somehow.
     for (let window of WindowListManager.browserWindows()) {
+      /* istanbul ignore if */
       if (!window.gBrowser) {
         continue;
       }
       for (let tab of window.gBrowser.tabs) {
         if (this.getId(tab) == tabId) {
           return tab;
         }
       }
     }
     return null;
   },
 
   get activeTab() {
     let window = WindowManager.topWindow;
+    /* istanbul ignore else */
     if (window && window.gBrowser) {
       return window.gBrowser.selectedTab;
     }
+    /* istanbul ignore next */
     return null;
   },
 
   getStatus(tab) {
     return tab.getAttribute("busy") == "true" ? "loading" : "complete";
   },
 
   convert(extension, tab) {
@@ -716,16 +724,17 @@ global.WindowListManager = {
     // fires for browser windows when they're in that in-between state, and just
     // before we register our own "domwindowcreated" listener.
 
     let e = Services.wm.getEnumerator("");
     while (e.hasMoreElements()) {
       let window = e.getNext();
 
       let ok = includeIncomplete;
+      /* istanbul ignore else */
       if (window.document.readyState == "complete") {
         ok = window.document.documentElement.getAttribute("windowtype") == "navigator:browser";
       }
 
       if (ok) {
         yield window;
       }
     }
@@ -763,27 +772,29 @@ global.WindowListManager = {
     if (this._openListeners.size == 0 && this._closeListeners.size == 0) {
       Services.ww.unregisterNotification(this);
     }
   },
 
   handleEvent(event) {
     event.currentTarget.removeEventListener(event.type, this);
     let window = event.target.defaultView;
+    /* istanbul ignore if */
     if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") {
       return;
     }
 
     for (let listener of this._openListeners) {
       listener(window);
     }
   },
 
   observe(window, topic, data) {
     if (topic == "domwindowclosed") {
+      /* istanbul ignore if */
       if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") {
         return;
       }
 
       window.removeEventListener("load", this);
       for (let listener of this._closeListeners) {
         listener(window);
       }
--- a/browser/components/extensions/test/xpcshell/head.js
+++ b/browser/components/extensions/test/xpcshell/head.js
@@ -9,16 +9,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "WebExtCoverage",
+                                  "resource://gre/modules/WebExtCoverage.jsm");
+
+do_register_cleanup(() => WebExtCoverage.saveAllCoverage(false));
 
 Cu.import("resource://gre/modules/ExtensionManagement.jsm");
 
 /* exported normalizeManifest */
 
 let BASE_MANIFEST = {
   "applications": {"gecko": {"id": "test@web.ext"}},
 
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -53,16 +53,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 
 Cu.import("resource://gre/modules/ExtensionManagement.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 // Register built-in parts of the API. Other parts may be registered
 // in browser/, mobile/, or b2g/.
 ExtensionManagement.registerScript("chrome://extensions/content/ext-alarms.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-backgroundPage.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-cookies.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-downloads.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-notifications.js");
@@ -164,16 +167,17 @@ var Management = {
   // parameter should be an object of the form:
   // {
   //   tabs: {
   //     create: ...,
   //     onCreated: ...
   //   }
   // }
   // This registers tabs.create and tabs.onCreated as part of the API.
+  /* istanbul ignore next */
   registerAPI(api) {
     this.apis.push({api});
   },
 
   // Same as above, but only register the API is the add-on has the
   // given permission.
   registerPrivilegedAPI(permission, api) {
     this.apis.push({api, permission});
@@ -485,16 +489,17 @@ let UninstallObserver = {
     if (!this.initialized) {
       AddonManager.addAddonListener(this);
       this.initialized = true;
     }
   },
 
   onUninstalling: function(addon) {
     let extension = GlobalManager.extensionMap.get(addon.id);
+    /* istanbul ignore else */
     if (extension) {
       Management.emit("uninstall", extension);
     }
   },
 };
 
 // Responsible for loading extension APIs into the right globals.
 GlobalManager = {
@@ -713,16 +718,17 @@ GlobalManager = {
 function getExtensionUUID(id) {
   const PREF_NAME = "extensions.webextensions.uuids";
 
   let pref = Preferences.get(PREF_NAME, "{}");
   let map = {};
   try {
     map = JSON.parse(pref);
   } catch (e) {
+    /* istanbul ignore next */
     Cu.reportError(`Error parsing ${PREF_NAME}.`);
   }
 
   if (id in map) {
     return map[id];
   }
 
   let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
@@ -809,16 +815,17 @@ ExtensionData.prototype = {
         // Always return a list, even if the directory does not exist (or is
         // not a directory) for symmetry with the ZipReader behavior.
       }
       iter.close();
 
       return results;
     }
 
+    /* istanbul ignore if */
     if (!(this.rootURI instanceof Ci.nsIJARURI &&
           this.rootURI.JARFile instanceof Ci.nsIFileURL)) {
       // This currently happens for app:// URLs passed to us by
       // UserCustomizations.jsm
       return [];
     }
 
     // FIXME: We need a way to do this without main thread IO.
@@ -834,16 +841,17 @@ ExtensionData.prototype = {
       path = path.replace(/\/\/+/g, "/").replace(/^\/|\/$/g, "") + "/";
 
       // Escape pattern metacharacters.
       let pattern = path.replace(/[[\]()?*~|$\\]/g, "\\$&");
 
       let enumerator = zipReader.findEntries(pattern + "*");
       while (enumerator.hasMore()) {
         let name = enumerator.getNext();
+        /* istanbul ignore if */
         if (!name.startsWith(path)) {
           throw new Error("Unexpected ZipReader entry");
         }
 
         // The enumerator returns the full path of all entries.
         // Trim off the leading path, and filter out entries from
         // subdirectories.
         name = name.slice(path.length);
@@ -861,16 +869,17 @@ ExtensionData.prototype = {
     }
   }),
 
   readJSON(path) {
     return new Promise((resolve, reject) => {
       let uri = this.rootURI.resolve(`./${path}`);
 
       NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => {
+        /* istanbul ignore if */
         if (!Components.isSuccessCode(status)) {
           reject(new Error(status));
           return;
         }
         try {
           let text = NetUtil.readInputStreamToString(inputStream, inputStream.available(),
                                                      {charset: "utf-8"});
 
@@ -983,16 +992,17 @@ ExtensionData.prototype = {
   // Map(gecko-locale-code -> locale-directory-name)
   promiseLocales() {
     if (!this._promiseLocales) {
       this._promiseLocales = Task.spawn(function* () {
         let locales = new Map();
 
         let entries = yield this.readDirectory("_locales");
         for (let file of entries) {
+          /* istanbul ignore else */
           if (file.isDir) {
             let locale = this.normalizeLocaleCode(file.name);
             locales.set(locale, file.name);
           }
         }
 
         this.localeData = new LocaleData({
           defaultLocale: this.defaultLocale,
@@ -1347,16 +1357,17 @@ Extension.prototype = extend(Object.crea
       localeData: this.localeData.serialize(),
       permissions: this.permissions,
     };
   },
 
   broadcast(msg, data) {
     return new Promise(resolve => {
       let count = Services.ppmm.childCount;
+      /* istanbul ignore if */
       if (AppConstants.MOZ_NUWA_PROCESS) {
         // The nuwa process is frozen, so don't expect it to answer.
         count--;
       }
       Services.ppmm.addMessageListener(msg + "Complete", function listener() {
         count--;
         if (count == 0) {
           Services.ppmm.removeMessageListener(msg + "Complete", listener);
@@ -1440,16 +1451,17 @@ Extension.prototype = extend(Object.crea
     return this.readManifest().then(() => {
       ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this);
       started = true;
 
       if (!this.hasShutdown) {
         return this.initLocale();
       }
     }).then(() => {
+      /* istanbul ignore if */
       if (this.errors.length) {
         // b2g add-ons generate manifest errors that we've silently
         // ignoring prior to adding this check.
         if (!this.rootURI.schemeIs("app")) {
           return Promise.reject({errors: this.errors});
         }
       }
 
@@ -1457,17 +1469,17 @@ Extension.prototype = extend(Object.crea
         return;
       }
 
       GlobalManager.init(this);
 
       Management.emit("startup", this);
 
       return this.runManifest(this.manifest);
-    }).catch(e => {
+    }).catch(/* istanbul ignore next */ e => {
       dump(`Extension error: ${e.message} ${e.filename || e.fileName}:${e.lineNumber} :: ${e.stack || new Error().stack}\n`);
       Cu.reportError(e);
 
       if (started) {
         ExtensionManagement.shutdownExtension(this.uuid);
       }
 
       this.cleanupGeneratedFile();
@@ -1520,16 +1532,17 @@ Extension.prototype = extend(Object.crea
 
     MessageChannel.abortResponses({extensionId: this.id});
 
     ExtensionManagement.shutdownExtension(this.uuid);
 
     this.cleanupGeneratedFile();
   },
 
+  /* istanbul ignore next */
   observe(subject, topic, data) {
     if (topic == "xpcom-shutdown") {
       this.cleanupGeneratedFile();
     }
   },
 
   hasPermission(perm) {
     return this.permissions.has(perm);
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -17,16 +17,19 @@ this.EXPORTED_SYMBOLS = ["ExtensionConte
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
                                   "resource://gre/modules/ExtensionManagement.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
                                   "resource:///modules/translation/LanguageDetector.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchGlobs",
@@ -258,16 +261,17 @@ Script.prototype = {
 
     let result;
     let scheduled = this.run_at || "document_idle";
     if (shouldRun(scheduled)) {
       for (let url of this.js) {
         // On gonk we need to load the resources asynchronously because the
         // app: channels only support asyncOpen. This is safe only in the
         // `document_idle` state.
+        /* istanbul ignore if */
         if (AppConstants.platform == "gonk" && scheduled != "document_idle") {
           Cu.reportError(`Script injection: ignoring ${url} at ${scheduled}`);
           continue;
         }
         url = this.extension.baseURI.resolve(url);
 
         let options = {
           target: sandbox,
@@ -344,25 +348,27 @@ class ExtensionContext extends BaseConte
     // copy origin attributes from the content window origin attributes to
     // preserve the user context id. overwrite the addonId.
     let attrs = contentPrincipal.originAttributes;
     attrs.addonId = extensionId;
     let extensionPrincipal = ssm.createCodebasePrincipal(this.extension.baseURI, attrs);
     Object.defineProperty(this, "principal",
                           {value: extensionPrincipal, enumerable: true, configurable: true});
 
+    /* istanbul ignore if */
     if (ssm.isSystemPrincipal(contentPrincipal)) {
       // Make sure we don't hand out the system principal by accident.
       // also make sure that the null principal has the right origin attributes
       prin = ssm.createNullPrincipal(attrs);
     } else {
       prin = [contentPrincipal, extensionPrincipal];
     }
 
     if (isExtensionPage) {
+      /* istanbul ignore if */
       if (ExtensionManagement.getAddonIdForWindow(this.contentWindow) != extensionId) {
         throw new Error("Invalid target window for this extension context");
       }
       // This is an iframe with content script API enabled and its principal should be the
       // contentWindow itself. (we create a sandbox with the contentWindow as principal and with X-rays disabled
       // because it enables us to create the APIs object in this sandbox object and then copying it
       // into the iframe's window, see Bug 1214658 for rationale)
       this.sandbox = Cu.Sandbox(contentWindow, {
@@ -562,16 +568,17 @@ DocumentManager = {
         DocumentManager.getExtensionPageContext(extensionId, window);
       }
 
       this.trigger("document_start", window);
       /* eslint-disable mozilla/balanced-listeners */
       window.addEventListener("DOMContentLoaded", this, true);
       window.addEventListener("load", this, true);
       /* eslint-enable mozilla/balanced-listeners */
+      /* istanbul ignore else */
     } else if (topic == "inner-window-destroyed") {
       let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
 
       MessageChannel.abortResponses({innerWindowID: windowId});
 
       // Close any existent content-script context for the destroyed window.
       if (this.contentScriptWindows.has(windowId)) {
         let extensions = this.contentScriptWindows.get(windowId);
@@ -588,16 +595,17 @@ DocumentManager = {
         context.close();
         this.extensionPageWindows.delete(windowId);
       }
     }
   },
 
   handleEvent: function(event) {
     let window = event.currentTarget;
+    /* istanbul ignore if */
     if (event.target != window.document) {
       // We use capturing listeners so we have precedence over content script
       // listeners, but only care about events targeted to the element we're
       // listening on.
       return;
     }
     window.removeEventListener(event.type, this, true);
 
@@ -630,16 +638,17 @@ DocumentManager = {
                         .filter(promise => promise);
 
     if (!promises.length) {
       return Promise.reject({message: `No matching window`});
     }
     if (options.all_frames) {
       return Promise.all(promises);
     }
+    /* istanbul ignore if */
     if (promises.length > 1) {
       return Promise.reject({message: `Internal error: Script matched multiple windows`});
     }
     return promises[0];
   },
 
   enumerateWindows: function* (docShell) {
     let window = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
@@ -677,16 +686,17 @@ DocumentManager = {
 
     return extensions.get(extensionId);
   },
 
   getExtensionPageContext(extensionId, window) {
     let winId = getInnerWindowID(window);
 
     let context = this.extensionPageWindows.get(winId);
+    /* istanbul ignore else */
     if (!context) {
       let context = new ExtensionContext(extensionId, window, {isExtensionPage: true});
       this.extensionPageWindows.set(winId, context);
     }
 
     return context;
   },
 
@@ -719,16 +729,17 @@ DocumentManager = {
       if (context) {
         context.close();
         extensions.delete(extensionId);
       }
     }
 
     // Clean up iframe extension page contexts on extension shutdown.
     for (let [winId, context] of this.extensionPageWindows) {
+      /* istanbul ignore else */
       if (context.extensionId == extensionId) {
         context.close();
         this.extensionPageWindows.delete(winId);
       }
     }
 
     MessageChannel.abortResponses({extensionId});
 
--- a/toolkit/components/extensions/ExtensionManagement.jsm
+++ b/toolkit/components/extensions/ExtensionManagement.jsm
@@ -9,16 +9,19 @@ this.EXPORTED_SYMBOLS = ["ExtensionManag
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 /*
  * This file should be kept short and simple since it's loaded even
  * when no extensions are running.
  */
 
 // Keep track of frame IDs for content windows. Mostly we can just use
 // the outer window ID as the frame ID. However, the API specifies
@@ -142,16 +145,17 @@ var Service = {
       this.init();
     }
 
     // Create the moz-extension://uuid mapping.
     // On b2g, in content processes we can't load jar:file:/// content, so we
     // switch to jar:remoteopenfile:/// instead
     // This is mostly exercised by generated extensions in tests. Installed
     // extensions in b2g get an app: uri that also maps to the right jar: uri.
+    /* istanbul ignore next */
     if (AppConstants.MOZ_B2G &&
         Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT &&
         uri.spec.startsWith("jar:file://")) {
       uri = Services.io.newURI("jar:remoteopen" + uri.spec.substr("jar:".length), null, null);
     }
 
     let handler = Services.io.getProtocolHandler("moz-extension");
     handler.QueryInterface(Ci.nsISubstitutingProtocolHandler);
@@ -184,16 +188,17 @@ var Service = {
   extensionURILoadableByAnyone(uri) {
     let uuid = uri.host;
     let extension = this.uuidMap.get(uuid);
     if (!extension || !extension.webAccessibleResources) {
       return false;
     }
 
     let path = uri.QueryInterface(Ci.nsIURL).filePath;
+    /* istanbul ignore else */
     if (path.length > 0 && path[0] == "/") {
       path = path.substr(1);
     }
     return extension.webAccessibleResources.matches(path);
   },
 
   // Checks whether a given extension can load this URI (typically via
   // an XML HTTP request). The manifest.json |permissions| directive
--- a/toolkit/components/extensions/ExtensionStorage.jsm
+++ b/toolkit/components/extensions/ExtensionStorage.jsm
@@ -10,16 +10,19 @@ const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/osfile.jsm");
 Cu.import("resource://gre/modules/AsyncShutdown.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 /* globals OS ExtensionStorage */
 
 var Path = OS.Path;
 var profileDir = OS.Constants.Path.profileDir;
 
 function jsonReplacer(key, value) {
   switch (typeof(value)) {
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -10,48 +10,55 @@ const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 const INTEGER = /^[1-9]\d*$/;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
                                   "resource:///modules/translation/LanguageDetector.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
                                   "resource://gre/modules/Preferences.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                   "resource://gre/modules/PromiseUtils.jsm");
 
+/* istanbul ignore next */
 function filterStack(error) {
   return String(error.stack).replace(/(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, "<Promise Chain>\n");
 }
 
 // Run a function and report exceptions.
 function runSafeSyncWithoutClone(f, ...args) {
   try {
     return f(...args);
   } catch (e) {
+    /* istanbul ignore next */
     dump(`Extension error: ${e} ${e.fileName} ${e.lineNumber}\n[[Exception stack\n${filterStack(e)}Current stack\n${filterStack(Error())}]]\n`);
+    /* istanbul ignore next */
     Cu.reportError(e);
   }
 }
 
 // Run a function and report exceptions.
 function runSafeWithoutClone(f, ...args) {
+  /* istanbul ignore if */
   if (typeof(f) != "function") {
     dump(`Extension error: expected function\n${filterStack(Error())}`);
     return;
   }
 
   Promise.resolve().then(() => {
     runSafeSyncWithoutClone(f, ...args);
   });
@@ -63,29 +70,33 @@ function runSafeSync(context, f, ...args
   if (context.unloaded) {
     Cu.reportError("runSafeSync called after context unloaded");
     return;
   }
 
   try {
     args = Cu.cloneInto(args, context.cloneScope);
   } catch (e) {
+    /* istanbul ignore next */
     Cu.reportError(e);
+    /* istanbul ignore next */
     dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${filterStack(Error())}`);
   }
   return runSafeSyncWithoutClone(f, ...args);
 }
 
 // Run a function, cloning arguments into context.cloneScope, and
 // report exceptions. |f| is expected to be in context.cloneScope.
 function runSafe(context, f, ...args) {
   try {
     args = Cu.cloneInto(args, context.cloneScope);
   } catch (e) {
+    /* istanbul ignore next */
     Cu.reportError(e);
+    /* istanbul ignore next */
     dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${filterStack(Error())}`);
   }
   if (context.unloaded) {
     dump(`runSafe failure: context is already unloaded ${filterStack(new Error())}\n`);
     return undefined;
   }
   return runSafeWithoutClone(f, ...args);
 }
@@ -151,20 +162,22 @@ class BaseContext {
     this._lastError = null;
     this.contextId = ++gContextId;
     this.unloaded = false;
     this.extensionId = extensionId;
     this.jsonSandbox = null;
     this.active = true;
   }
 
+  /* istanbul ignore next */
   get cloneScope() {
     throw new Error("Not implemented");
   }
 
+  /* istanbul ignore next */
   get principal() {
     throw new Error("Not implemented");
   }
 
   runSafe(...args) {
     if (this.unloaded) {
       Cu.reportError("context.runSafe called after context unloaded");
     } else {
@@ -536,17 +549,17 @@ let IconDetails = {
 
     return canvas.toDataURL("image/png");
   },
 };
 
 function LocaleData(data) {
   this.defaultLocale = data.defaultLocale;
   this.selectedLocale = data.selectedLocale;
-  this.locales = data.locales || new Map();
+  this.locales = data.locales || /* istanbul ignore next */ new Map();
   this.warnedMissingKeys = new Set();
 
   // Map(locale-name -> Map(message-key -> localized-string))
   //
   // Contains a key for each loaded locale, each of which is a
   // Map of message keys to their localized strings.
   this.messages = data.messages || new Map();
 
@@ -671,24 +684,26 @@ LocaleData.prototype = {
     // replacements. Later, it processes the resulting string for
     // |$[0-9]| replacements.
     //
     // Again, it does not document this, but it accepts any number
     // of sequential |$|s, and replaces them with that number minus
     // 1. It also accepts |$| followed by any number of sequential
     // digits, but refuses to process a localized string which
     // provides more than 9 substitutions.
+    /* istanbul ignore if */
     if (!instanceOf(messages, "Object")) {
       extension.packagingError(`Invalid locale data for ${locale}`);
       return result;
     }
 
     for (let key of Object.keys(messages)) {
       let msg = messages[key];
 
+      /* istanbul ignore if */
       if (!instanceOf(msg, "Object") || typeof(msg.message) != "string") {
         extension.packagingError(`Invalid locale message data for ${locale}, message ${JSON.stringify(key)}`);
         continue;
       }
 
       // Substitutions are case-insensitive, so normalize all of their names
       // to lower-case.
       let placeholders = new Map();
@@ -756,16 +771,17 @@ function EventManager(context, name, reg
   this.name = name;
   this.register = register;
   this.unregister = null;
   this.callbacks = new Set();
 }
 
 EventManager.prototype = {
   addListener(callback) {
+    /* istanbul ignore if */
     if (typeof(callback) != "function") {
       dump(`Expected function\n${Error().stack}`);
       return;
     }
 
     if (!this.callbacks.size) {
       this.context.callOnClose(this);
 
@@ -849,16 +865,17 @@ SingletonEventManager.prototype = {
       }
     };
 
     let unregister = this.register(wrappedCallback, ...args);
     this.unregister.set(callback, unregister);
   },
 
   removeListener(callback) {
+    /* istanbul ignore if */
     if (!this.unregister.has(callback)) {
       return;
     }
 
     let unregister = this.unregister.get(callback);
     this.unregister.delete(callback);
     unregister();
   },
@@ -905,16 +922,17 @@ function ignoreEvent(context, name) {
     hasListener: function(callback) {},
   };
 }
 
 // Copy an API object from |source| into the scope |dest|.
 function injectAPI(source, dest) {
   for (let prop in source) {
     // Skip names prefixed with '_'.
+    /* istanbul ignore if */
     if (prop[0] == "_") {
       continue;
     }
 
     let desc = Object.getOwnPropertyDescriptor(source, prop);
     if (typeof(desc.value) == "function") {
       Cu.exportFunction(desc.value, dest, {defineAs: prop});
     } else if (typeof(desc.value) == "object") {
@@ -984,31 +1002,33 @@ Port.prototype = {
       postMessage: json => {
         if (this.disconnected) {
           throw new this.context.contentWindow.Error("Attempt to postMessage on disconnected port");
         }
         this.messageManager.sendAsyncMessage(this.listenerName, json);
       },
       onDisconnect: new EventManager(this.context, "Port.onDisconnect", fire => {
         let listener = () => {
+          /* istanbul ignore else */
           if (!this.disconnected) {
             fire();
           }
         };
 
         this.disconnectListeners.add(listener);
         return () => {
           this.disconnectListeners.delete(listener);
         };
       }).api(),
       onMessage: new EventManager(this.context, "Port.onMessage", fire => {
         let listener = ({data}) => {
           if (!this.context.active) {
             // TODO: Send error as a response.
             Cu.reportError("Message received on port for an inactive content script");
+          /* istanbul ignore else */
           } else if (!this.disconnected) {
             fire(data);
           }
         };
 
         this.messageManager.addMessageListener(this.listenerName, listener);
         return () => {
           this.messageManager.removeMessageListener(this.listenerName, listener);
@@ -1027,16 +1047,17 @@ Port.prototype = {
   handleDisconnection() {
     this.messageManager.removeMessageListener(this.disconnectName, this);
     this.context.forgetOnClose(this);
     this.disconnected = true;
   },
 
   receiveMessage(msg) {
     if (msg.name == this.disconnectName) {
+      /* istanbul ignore if */
       if (this.disconnected) {
         return;
       }
 
       for (let listener of this.disconnectListeners) {
         listener();
       }
 
@@ -1120,16 +1141,17 @@ Messenger.prototype = {
       let listener = {
         messageFilterPermissive: this.filter,
 
         receiveMessage: ({target, data: message, sender, recipient}) => {
           if (!this.context.active) {
             return;
           }
 
+          /* istanbul ignore else */
           if (this.delegate) {
             this.delegate.getSender(this.context, target, sender);
           }
 
           let sendResponse;
           let response = undefined;
           let promise = new Promise(resolve => {
             sendResponse = value => {
--- a/toolkit/components/extensions/MessageChannel.jsm
+++ b/toolkit/components/extensions/MessageChannel.jsm
@@ -1,14 +1,18 @@
 /* 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/. */
 
 "use strict";
 
+Components.utils.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
+
 /**
  * This module provides wrappers around standard message managers to
  * simplify bidirectional communication. It currently allows a caller to
  * send a message to a single listener, and receive a reply. If there
  * are no matching listeners, or the message manager disconnects before
  * a reply is received, the caller is returned an error.
  *
  * The listener end may specify filters for the messages it wishes to
--- a/toolkit/components/extensions/NativeMessaging.jsm
+++ b/toolkit/components/extensions/NativeMessaging.jsm
@@ -30,16 +30,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout",
                                   "resource://gre/modules/Timer.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "setTimeout",
                                   "resource://gre/modules/Timer.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry",
                                   "resource://gre/modules/WindowsRegistry.jsm");
 
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+WebExtCoverage.register(this);
+
 const HOST_MANIFEST_SCHEMA = "chrome://extensions/content/schemas/native_host_manifest.json";
 const VALID_APPLICATION = /^\w+(\.\w+)*$/;
 
 // For a graceful shutdown (i.e., when the extension is unloaded or when it
 // explicitly calls disconnect() on a native port), how long we give the native
 // application to exit before we start trying to kill it.  (in milliseconds)
 const GRACEFUL_SHUTDOWN_TIME = 3000;
 
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -9,16 +9,19 @@ const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.importGlobalProperties(["URL"]);
 
 Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   instanceOf,
 } = ExtensionUtils;
 
 XPCOMUtils.defineLazyServiceGetter(this, "contentPolicyService",
                                    "@mozilla.org/addons/content-policy;1",
@@ -26,32 +29,34 @@ XPCOMUtils.defineLazyServiceGetter(this,
 
 this.EXPORTED_SYMBOLS = ["Schemas"];
 
 /* globals Schemas, URL */
 
 function readJSON(url) {
   return new Promise((resolve, reject) => {
     NetUtil.asyncFetch({uri: url, loadUsingSystemPrincipal: true}, (inputStream, status) => {
+      /* istanbul ignore if */
       if (!Components.isSuccessCode(status)) {
         reject(new Error(status));
         return;
       }
       try {
         let text = NetUtil.readInputStreamToString(inputStream, inputStream.available());
 
         // Chrome JSON files include a license comment that we need to
         // strip off for this to be valid JSON. As a hack, we just
         // look for the first '[' character, which signals the start
         // of the JSON content.
         let index = text.indexOf("[");
         text = text.slice(index);
 
         resolve(JSON.parse(text));
       } catch (e) {
+        /* istanbul ignore next */
         reject(e);
       }
     });
   });
 }
 
 // Parses a regular expression, with support for the Python extended
 // syntax that allows setting flags by including the string (?im)
@@ -431,16 +436,17 @@ class Entry {
   logDeprecation(context, value = null) {
     let message = "This property is deprecated";
     if (typeof(this.deprecated) == "string") {
       message = this.deprecated;
       if (message.includes("${value}")) {
         try {
           value = JSON.stringify(value);
         } catch (e) {
+          /* istanbul ignore next */
           value = String(value);
         }
         message = message.replace(/\$\{value\}/g, () => value);
       }
     }
 
     context.logError(context.makeError(message));
   }
@@ -470,25 +476,27 @@ class Entry {
 // Corresponds either to a type declared in the "types" section of the
 // schema or else to any type object used throughout the schema.
 class Type extends Entry {
   // Takes a value, checks that it has the correct type, and returns a
   // "normalized" version of the value. The normalized version will
   // include "nulls" in place of omitted optional properties. The
   // result of this function is either {error: "Some type error"} or
   // {value: <normalized-value>}.
+  /* istanbul ignore next */
   normalize(value, context) {
     return context.error("invalid type");
   }
 
   // Unlike normalize, this function does a shallow check to see if
   // |baseType| (one of the possible getValueBaseType results) is
   // valid for this type. It returns true or false. It's used to fill
   // in optional arguments to functions before actually type checking
   // the arguments.
+  /* istanbul ignore next */
   checkBaseType(baseType) {
     return false;
   }
 
   // Helper method that simply relies on checkBaseType to implement
   // normalize. Subclasses can choose to use it or not.
   normalizeBase(type, value, context) {
     if (this.checkBaseType(getValueBaseType(value))) {
@@ -576,16 +584,17 @@ class RefType extends Type {
     super(schema);
     this.namespaceName = namespaceName;
     this.reference = reference;
   }
 
   get targetType() {
     let ns = Schemas.namespaces.get(this.namespaceName);
     let type = ns.get(this.reference);
+    /* istanbul ignore if */
     if (!type) {
       throw new Error(`Internal error: Type ${this.reference} not found`);
     }
     return type;
   }
 
   normalize(value, context) {
     this.checkDeprecated(context, value);
@@ -671,16 +680,17 @@ class ObjectType extends Type {
     this.properties = properties;
     this.additionalProperties = additionalProperties;
     this.patternProperties = patternProperties;
     this.isInstanceOf = isInstanceOf;
   }
 
   extend(type) {
     for (let key of Object.keys(type.properties)) {
+      /* istanbul ignore if */
       if (key in this.properties) {
         throw new Error(`InternalError: Attempt to extend an object with conflicting property "${key}"`);
       }
       this.properties[key] = type.properties[key];
     }
 
     this.patternProperties.push(...type.patternProperties);
 
@@ -695,16 +705,17 @@ class ObjectType extends Type {
   normalize(value, context) { // eslint-disable-line complexity
     let v = this.normalizeBase("object", value, context);
     if (v.error) {
       return v;
     }
     value = v.value;
 
     if (this.isInstanceOf) {
+      /* istanbul ignore next */
       if (Object.keys(this.properties).length ||
           this.patternProperties.length ||
           !(this.additionalProperties instanceof AnyType)) {
         throw new Error("InternalError: isInstanceOf can only be used with objects that are otherwise unrestricted");
       }
 
       if (!instanceOf(value, this.isInstanceOf)) {
         return context.error(`Object must be an instance of ${this.isInstanceOf}`,
@@ -1044,16 +1055,17 @@ class SubModuleProperty extends Entry {
     this.properties = properties;
   }
 
   inject(path, name, dest, wrapperFuncs) {
     let obj = Cu.createObjectIn(dest, {defineAs: name});
 
     let ns = Schemas.namespaces.get(this.namespaceName);
     let type = ns.get(this.reference);
+    /* istanbul ignore next */
     if (!type || !(type instanceof SubModuleType)) {
       throw new Error(`Internal error: ${this.namespaceName}.${this.reference} is not a sub-module`);
     }
 
     let functions = type.functions;
     for (let fun of functions) {
       fun.inject(path.concat(name), fun.name, obj, wrapperFuncs);
     }
@@ -1273,16 +1285,17 @@ this.Schemas = {
   // FIXME: Bug 1265371 - Refactor normalize and parseType in Schemas.jsm to reduce complexity
   parseType(path, type, extraProperties = []) { // eslint-disable-line complexity
     let allowedProperties = new Set(extraProperties);
 
     // Do some simple validation of our own schemas.
     function checkTypeProperties(...extra) {
       let allowedSet = new Set([...allowedProperties, ...extra, "description", "deprecated", "preprocess"]);
       for (let prop of Object.keys(type)) {
+        /* istanbul ignore if */
         if (!allowedSet.has(prop)) {
           throw new Error(`Internal error: Namespace ${path.join(".")} has invalid type property "${prop}" in type "${type.id || JSON.stringify(type)}"`);
         }
       }
     }
 
     if ("choices" in type) {
       checkTypeProperties("choices");
@@ -1323,22 +1336,24 @@ this.Schemas = {
         });
       }
 
       let pattern = null;
       if (type.pattern) {
         try {
           pattern = parsePattern(type.pattern);
         } catch (e) {
+          /* istanbul ignore next */
           throw new Error(`Internal error: Invalid pattern ${JSON.stringify(type.pattern)}`);
         }
       }
 
       let format = null;
       if (type.format) {
+        /* istanbul ignore if */
         if (!(type.format in FORMATS)) {
           throw new Error(`Internal error: Invalid string format ${type.format}`);
         }
         format = FORMATS[type.format];
       }
       return new StringType(type, enumeration,
                             type.minLength || 0,
                             type.maxLength || Infinity,
@@ -1368,16 +1383,17 @@ this.Schemas = {
       }
 
       let patternProperties = [];
       for (let propName of Object.keys(type.patternProperties || {})) {
         let pattern;
         try {
           pattern = parsePattern(propName);
         } catch (e) {
+          /* istanbul ignore next */
           throw new Error(`Internal error: Invalid property pattern ${JSON.stringify(propName)}`);
         }
 
         patternProperties.push({
           pattern,
           type: parseProperty(type.patternProperties[propName]),
         });
       }
@@ -1426,23 +1442,25 @@ this.Schemas = {
         }
       }
 
       let hasAsyncCallback = false;
       if (isAsync) {
         if (parameters && parameters.length && parameters[parameters.length - 1].name == type.async) {
           hasAsyncCallback = true;
         }
+        /* istanbul ignore next */
         if (type.returns || type.allowAmbiguousOptionalArguments) {
           throw new Error(`Internal error: Async functions must not have return values or ambiguous arguments.`);
         }
       }
 
       checkTypeProperties("parameters", "async", "returns");
       return new FunctionType(type, parameters, isAsync, hasAsyncCallback);
+    /* istanbul ignore else */
     } else if (type.type == "any") {
       // Need to see what minimum and maximum are supposed to do here.
       checkTypeProperties("minimum", "maximum");
       return new AnyType(type);
     } else {
       throw new Error(`Unexpected type ${type.type}`);
     }
   },
@@ -1470,23 +1488,26 @@ this.Schemas = {
 
   extendType(namespaceName, type) {
     let ns = Schemas.namespaces.get(namespaceName);
     let targetType = ns && ns.get(type.$extend);
 
     // Only allow extending object and choices types for now.
     if (targetType instanceof ObjectType) {
       type.type = "object";
+    /* istanbul ignore if */
     } else if (!targetType) {
       throw new Error(`Internal error: Attempt to extend a nonexistant type ${type.$extend}`);
+    /* istanbul ignore if */
     } else if (!(targetType instanceof ChoiceType)) {
       throw new Error(`Internal error: Attempt to extend a non-extensible type ${type.$extend}`);
     }
 
     let parsed = this.parseType([namespaceName], type, ["$extend"]);
+    /* istanbul ignore if */
     if (parsed.constructor !== targetType.constructor) {
       throw new Error(`Internal error: Bad attempt to extend ${type.$extend}`);
     }
 
     targetType.extend(parsed);
   },
 
   loadProperty(namespaceName, name, prop) {
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -67,16 +67,17 @@ BackgroundPage.prototype = {
                   .then(addon => addon.setDebugGlobal(window));
     }
 
     // TODO: Right now we run onStartup after the background page
     // finishes. See if this is what Chrome does.
     // TODO(robwu): This implementation of onStartup is wrong, see
     // https://bugzilla.mozilla.org/show_bug.cgi?id=1247435#c1
     let loadListener = event => {
+      /* istanbul ignore if */
       if (event.target != window.document) {
         return;
       }
       event.currentTarget.removeEventListener("load", loadListener, true);
 
       if (this.extension.onStartup) {
         this.extension.onStartup();
       }
--- a/toolkit/components/extensions/ext-cookies.js
+++ b/toolkit/components/extensions/ext-cookies.js
@@ -86,23 +86,25 @@ function checkSetCookiePermissions(exten
   cookie.host = cookie.host.toLowerCase();
 
   if (cookie.host != uri.host) {
     // Not an exact match, so check for a valid subdomain.
     let baseDomain;
     try {
       baseDomain = Services.eTLD.getBaseDomain(uri);
     } catch (e) {
+      /* istanbul ignore else */
       if (e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS ||
           e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS) {
         // The cookie service uses these to determine whether the domain
         // requires an exact match. We already know we don't have an exact
         // match, so return false. In all other cases, re-raise the error.
         return false;
       }
+      /* istanbul ignore next */
       throw e;
     }
 
     // The cookie domain must be a subdomain of the base domain. This prevents
     // us from setting cookies for domains like ".co.uk".
     // The domain of the requesting URL must likewise be a subdomain of the
     // cookie domain. This prevents us from setting cookies for entirely
     // unrelated domains.
--- a/toolkit/components/extensions/ext-test.js
+++ b/toolkit/components/extensions/ext-test.js
@@ -31,25 +31,25 @@ extensions.registerSchemaAPI("test", (ex
       sendMessage: function(...args) {
         extension.emit("test-message", ...args);
       },
 
       notifyPass: function(msg) {
         extension.emit("test-done", true, msg);
       },
 
-      notifyFail: function(msg) {
+      notifyFail: /* istanbul ignore next */ function(msg) {
         extension.emit("test-done", false, msg);
       },
 
       log: function(msg) {
         extension.emit("test-log", true, msg);
       },
 
-      fail: function(msg) {
+      fail: /* istanbul ignore next */ function(msg) {
         extension.emit("test-result", false, msg);
       },
 
       succeed: function(msg) {
         extension.emit("test-result", true, msg);
       },
 
       assertTrue: function(value, msg) {
new file mode 100755
--- /dev/null
+++ b/toolkit/components/extensions/instrument_code.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python
+from __future__ import print_function
+import json
+import os
+import subprocess
+
+
+EXTS = '.js', '.jsm'
+
+ROOT = 'dist/bin'
+
+DATA_DIRS = ('dist/bin', 'dist/bin/browser')
+
+PATTERNS = ('dist/bin/%s/%s',
+            'dist/Nightly.app/Contents/Resources/%s/%s')
+
+CODE_DIRS = ('toolkit/components/extensions/',
+             'browser/components/extensions/',
+             'toolkit/modules/addons/',
+             'toolkit/components/utils/simpleServices.js')
+
+REPO = os.path.abspath('.')
+CODE_DIRS = tuple(os.path.join(REPO, d) for d in CODE_DIRS)
+
+processes = {}
+
+
+mach = subprocess.Popen(['./mach', 'environment', '--format=json'],
+                        stdout=subprocess.PIPE)
+
+data = mach.communicate()[0]
+if not isinstance(data, type(u'')):
+    # Oh, Python...
+    data = data.decode('utf-8')
+
+config = json.loads(data)
+
+print('Entering object directory %s' % config['topobjdir'])
+os.chdir(config['topobjdir'])
+
+
+def instrument(input, output):
+    print('Instrumenting %s' % input[len(REPO) + 1:])
+
+    proc = subprocess.Popen(['istanbul', 'instrument', input],
+                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    processes[output] = proc
+
+for data_dir in DATA_DIRS:
+    data_file = 'faster/install_%s' % data_dir.replace('/', '_')
+    with open(data_file) as f:
+        for line in f:
+            fields = line.rstrip().split('\x1f')
+            if len(fields) == 3:
+                _, output, source = fields
+                if source.startswith(CODE_DIRS) and source.endswith(EXTS):
+                    dir_ = data_dir[len(ROOT):]
+                    for pat in PATTERNS:
+                        file_ = pat % (dir_, output)
+                        if os.path.exists(file_):
+                            instrument(source, file_)
+
+for path, proc in processes.items():
+    stdout, stderr = proc.communicate()
+    if stdout and not stderr:
+        os.unlink(path)
+        with open(path, 'wb') as f:
+            f.write(stdout)
+    else:
+        print('Error processing "%s": %s' % (path, stderr))
--- a/toolkit/components/extensions/test/xpcshell/head.js
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -9,16 +9,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "WebExtCoverage",
+                                  "resource://gre/modules/WebExtCoverage.jsm");
+
+do_register_cleanup(() => WebExtCoverage.saveAllCoverage(false));
 
 /* exported normalizeManifest */
 
 let BASE_MANIFEST = {
   "applications": {"gecko": {"id": "test@web.ext"}},
 
   "manifest_version": 2,
 
new file mode 100755
--- /dev/null
+++ b/toolkit/components/extensions/test_coverage.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+set -e
+
+IFS="$(echo)"
+cd $(hg root)
+
+echo Instrumenting WebExtension code
+${PYTHON:-python} toolkit/components/extensions/instrument_code.py
+
+: ${MACH:=./mach}
+
+if test -n "$GECKO_JS_COVERAGE_OUTPUT_DIR"
+then tmpdir="$GECKO_JS_COVERAGE_OUTPUT_DIR"
+else tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/webext-coverage.XXXXXX")
+fi
+export GECKO_JS_COVERAGE_OUTPUT_DIR="$tmpdir"
+
+echo
+echo Outputting coverage data to: $tmpdir
+echo
+
+mochi() {
+  ${MACH} mochitest --keep-open=false "$@"
+  ${MACH} mochitest --keep-open=false --disable-e10s "$@"
+}
+
+mochi --quiet toolkit/components/extensions/test/mochitest
+mochi --quiet browser/components/extensions/test/browser
+${MACH} xpcshell-test toolkit/components/extensions/test/xpcshell
+${MACH} xpcshell-test \
+  toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js \
+  toolkit/mozapps/extensions/test/xpcshell/test_webextension.js \
+  toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
+
+
+cd "$tmpdir"
+mkdir coverage
+
+echo
+echo Generating full coverage report at "$tmpdir/coverage/index.html"
+istanbul report html
+echo
+
+for dir in content default
+do
+  echo Generating $dir process coverage report at "$tmpdir/coverage/$dir/index.html"
+  istanbul report --dir "coverage/$dir" --include "coverage-$dir-*.json" html
+  echo
+done
+
+# vim:se sts=2 sw=2 et ft=sh:
--- a/toolkit/components/utils/simpleServices.js
+++ b/toolkit/components/utils/simpleServices.js
@@ -13,16 +13,19 @@
 "use strict";
 
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Ci = Components.interfaces;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
 function AddonPolicyService()
 {
--- a/toolkit/modules/addons/MatchPattern.jsm
+++ b/toolkit/modules/addons/MatchPattern.jsm
@@ -9,16 +9,20 @@ const Ci = Components.interfaces;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
+
 this.EXPORTED_SYMBOLS = ["MatchPattern", "MatchGlobs", "MatchURLFilters"];
 
 /* globals MatchPattern, MatchGlobs */
 
 const PERMITTED_SCHEMES = ["http", "https", "file", "ftp", "app", "data"];
 const PERMITTED_SCHEMES_REGEXP = PERMITTED_SCHEMES.join("|");
 
 // This function converts a glob pattern (containing * and possibly ?
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/addons/WebExtCoverage.jsm
@@ -0,0 +1,149 @@
+/* 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/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+this.EXPORTED_SYMBOLS = ["WebExtCoverage"];
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+                                  "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "environment",
+                                   "@mozilla.org/process/environment;1",
+                                   Ci.nsIEnvironment);
+/* globals environment */
+
+
+const COVERAGE_PROPERTY = "__coverage__";
+const ENV_COVERAGE_OUTPUT_DIR = "GECKO_JS_COVERAGE_OUTPUT_DIR";
+
+const COVERAGE_MESSAGE = "WebExtCoverage:Update";
+
+const PROCESS_TYPES = Object.freeze({
+  [Services.appinfo.PROCESS_TYPE_DEFAULT]: "default",
+  [Services.appinfo.PROCESS_TYPE_CONTENT]: "content",
+});
+
+
+let globalID = 0;
+let initialized = false;
+
+const isContent = Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+function mangleFilename(filename) {
+  return filename.replace(new RegExp("^(resource|chrome)://[^/]+/|^file://.*/obj[^/]*/dist/bin/"), "")
+                 .replace(/\//g, "-");
+}
+
+this.WebExtCoverage = {
+  promises: new Set(),
+
+  coverageObjects: new Map(),
+
+  init() {
+    if (initialized) {
+      return this.outputDir;
+    }
+    initialized = true;
+
+    this.outputDir = environment.get(ENV_COVERAGE_OUTPUT_DIR);
+    if (!this.outputDir) {
+      return false;
+    }
+
+    let {processID, processType} = Services.appinfo;
+    this.baseFilename = `coverage-${PROCESS_TYPES[processType]}-pid_${processID}`;
+
+    if (!isContent) {
+      OS.File.profileBeforeChange.addBlocker("WebExtension coverage data flush",
+                                             () => this.saveAllCoverage());
+
+      Services.ppmm.addMessageListener(COVERAGE_MESSAGE, this, true);
+    } else {
+      Services.obs.addObserver(this, "content-child-shutdown", false);
+    }
+
+    return true;
+  },
+
+  observe() {
+    this.saveAllCoverage();
+  },
+
+  receiveMessage({data}) {
+    for (let [key, coverage] of data.entries()) {
+      this.writeCoverageFile(key, coverage);
+    }
+  },
+
+  register(global) {
+    if (!(COVERAGE_PROPERTY in global)) {
+      return;
+    }
+    if (!this.init()) {
+      return;
+    }
+
+    let name = [this.baseFilename,
+                mangleFilename(Components.stack.caller.filename),
+                globalID++].join("-");
+
+    let outputFile = `${name}.json`;
+
+    this.coverageObjects.set(outputFile, global[COVERAGE_PROPERTY]);
+
+    if ("addEventListener" in global) {
+      let listener = () => {
+        global.removeEventListener("unload", listener);
+        this.saveCoverage(outputFile, true);
+      };
+      global.addEventListener("unload", listener);
+    }
+  },
+
+  writeCoverageFile(filename, coverage) {
+    if (isContent) {
+      Services.cpmm.sendAsyncMessage(
+        COVERAGE_MESSAGE,
+        new Map([[filename, coverage]]));
+
+      return Promise.resolve();
+    }
+
+    let path = OS.Path.join(this.outputDir, filename);
+    let promise = OS.File.writeAtomic(path, JSON.stringify(coverage),
+                                      {tmpPath: `${path}.tmp`});
+
+    this.promises.add(promise);
+    return promise.then(() => {
+      this.promises.delete(promise);
+    });
+  },
+
+  saveCoverage(filename, finalize = true) {
+    let coverage = this.coverageObjects.get(filename);
+    if (finalize) {
+      this.coverageObjects.delete(filename);
+    }
+
+    return this.writeCoverageFile(filename, coverage);
+  },
+
+  saveAllCoverage(finalize = true) {
+    if (isContent) {
+      Services.cpmm.sendAsyncMessage(COVERAGE_MESSAGE, this.coverageObjects);
+      return Promise.resolve();
+    }
+
+    for (let filename of this.coverageObjects.keys()) {
+      this.saveCoverage(filename, finalize);
+    }
+
+    return Promise.all(this.promises);
+  },
+};
--- a/toolkit/modules/addons/WebNavigation.jsm
+++ b/toolkit/modules/addons/WebNavigation.jsm
@@ -7,16 +7,19 @@
 const EXPORTED_SYMBOLS = ["WebNavigation"];
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
                                   "resource:///modules/RecentWindow.jsm");
 
 // Maximum amount of time that can be passed and still consider
 // the data recent (similar to how is done in nsNavHistory,
 // e.g. nsNavHistory::CheckIsRecentEvent, but with a lower threshold value).
 const RECENT_DATA_THRESHOLD = 5 * 1000000;
--- a/toolkit/modules/addons/WebNavigationContent.js
+++ b/toolkit/modules/addons/WebNavigationContent.js
@@ -1,15 +1,18 @@
 "use strict";
 
 /* globals docShell */
 
 var Ci = Components.interfaces;
 
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                   "resource://gre/modules/WebNavigationFrames.jsm");
 
 function loadListener(event) {
   let document = event.target;
   let window = document.defaultView;
   let url = document.documentURI;
--- a/toolkit/modules/addons/WebNavigationFrames.jsm
+++ b/toolkit/modules/addons/WebNavigationFrames.jsm
@@ -3,16 +3,20 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const EXPORTED_SYMBOLS = ["WebNavigationFrames"];
 
 var Ci = Components.interfaces;
 
+Components.utils.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
+
 /* exported WebNavigationFrames */
 
 function getWindowId(window) {
   return window.QueryInterface(Ci.nsIInterfaceRequestor)
                .getInterface(Ci.nsIDOMWindowUtils)
                .outerWindowID;
 }
 
--- a/toolkit/modules/addons/WebRequest.jsm
+++ b/toolkit/modules/addons/WebRequest.jsm
@@ -12,16 +12,19 @@ const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 const {nsIHttpActivityObserver, nsISocketTransport} = Ci;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
                                   "resource://gre/modules/BrowserUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebRequestCommon",
                                   "resource://gre/modules/WebRequestCommon.jsm");
 
 function attachToChannel(channel, key, data) {
   if (channel instanceof Ci.nsIWritablePropertyBag2) {
--- a/toolkit/modules/addons/WebRequestCommon.jsm
+++ b/toolkit/modules/addons/WebRequestCommon.jsm
@@ -8,16 +8,20 @@ const EXPORTED_SYMBOLS = ["WebRequestCom
 
 /* exported WebRequestCommon */
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
+
 var WebRequestCommon = {
   typeForPolicyType(type) {
     switch (type) {
       case Ci.nsIContentPolicy.TYPE_DOCUMENT: return "main_frame";
       case Ci.nsIContentPolicy.TYPE_SUBDOCUMENT: return "sub_frame";
       case Ci.nsIContentPolicy.TYPE_STYLESHEET: return "stylesheet";
       case Ci.nsIContentPolicy.TYPE_SCRIPT: return "script";
       case Ci.nsIContentPolicy.TYPE_IMAGE: return "image";
--- a/toolkit/modules/addons/WebRequestContent.js
+++ b/toolkit/modules/addons/WebRequestContent.js
@@ -6,16 +6,19 @@
 
 var Ci = Components.interfaces;
 var Cc = Components.classes;
 var Cu = Components.utils;
 var Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/WebExtCoverage.jsm");
+
+WebExtCoverage.register(this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebRequestCommon",
                                   "resource://gre/modules/WebRequestCommon.jsm");
 
 const IS_HTTP = /^https?:/;
 
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -13,16 +13,17 @@ TESTING_JS_MODULES += [
     'tests/PromiseTestUtils.jsm',
     'tests/xpcshell/TestIntegration.jsm',
 ]
 
 SPHINX_TREES['toolkit_modules'] = 'docs'
 
 EXTRA_JS_MODULES += [
     'addons/MatchPattern.jsm',
+    'addons/WebExtCoverage.jsm',
     'addons/WebNavigation.jsm',
     'addons/WebNavigationContent.js',
     'addons/WebNavigationFrames.jsm',
     'addons/WebRequest.jsm',
     'addons/WebRequestCommon.jsm',
     'addons/WebRequestContent.js',
     'AsyncPrefs.jsm',
     'Battery.jsm',
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -43,16 +43,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "HttpServer",
                                   "resource://testing-common/httpd.js");
 XPCOMUtils.defineLazyModuleGetter(this, "MockRegistrar",
                                   "resource://testing-common/MockRegistrar.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MockRegistry",
                                   "resource://testing-common/MockRegistry.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "WebExtCoverage",
+                                  "resource://gre/modules/WebExtCoverage.jsm");
+
+do_register_cleanup(() => WebExtCoverage.saveAllCoverage(false));
 
 // We need some internal bits of AddonManager
 var AMscope = Components.utils.import("resource://gre/modules/AddonManager.jsm", {});
 var { AddonManager, AddonManagerInternal, AddonManagerPrivate } = AMscope;
 
 // Mock out AddonManager's reference to the AsyncShutdown module so we can shut
 // down AddonManager from the test
 var MockAsyncShutdown = {