Merge m-c to m-i
authorPhil Ringnalda <philringnalda@gmail.com>
Thu, 19 Jan 2017 22:12:33 -0800
changeset 375277 4ec5bc4fff15bf293a7118065df335847d0e2f96
parent 375276 2b1fcb4b8c8854eea5190c22bdd31c9aca3510f1 (current diff)
parent 375242 aa3e49299a3aa5cb0db570532e3df9e75d30c2d1 (diff)
child 375278 25c65ac95fc5191ded02ade757f0a3da819266b0
push id6996
push userjlorenzo@mozilla.com
push dateMon, 06 Mar 2017 20:48:21 +0000
treeherdermozilla-beta@d89512dab048 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge m-c to m-i
browser/tools/mozscreenshots/mozscreenshots/extension/configurations/DevEdition.jsm
new file mode 100644
--- /dev/null
+++ b/.cron.yml
@@ -0,0 +1,29 @@
+# Definitions for jobs that run periodically.  For details on the format, see
+# `taskcluster/taskgraph/cron/schema.py`.  For documentation, see
+# `taskcluster/docs/cron.rst`.
+
+jobs:
+    - name: nightly-desktop
+      job:
+          type: decision-task
+          treeherder-symbol: Nd
+          triggered-by: nightly
+          target-tasks-method: nightly_linux
+      projects:
+          - mozilla-central
+          - date
+      when:
+          - {hour: 16, minute: 0}
+
+    - name: nightly-android
+      job:
+          type: decision-task
+          treeherder-symbol: Na
+          triggered-by: nightly
+          target-tasks-method: nightly_fennec
+      projects:
+          - mozilla-central
+          - date
+      when:
+          - {hour: 16, minute: 0}
+
--- a/.taskcluster.yml
+++ b/.taskcluster.yml
@@ -8,47 +8,53 @@ metadata:
 
 scopes:
   # Note the below scopes are insecure however these get overriden on the server
   # side to whatever scopes are set by mozilla-taskcluster.
   - queue:*
   - docker-worker:*
   - scheduler:*
 
-# Available mustache parameters (see the mozilla-taskcluster source):
+# This file undergoes substitution to create tasks.  For on-push tasks, that
+# substitution is done by mozilla-taskcluster.  For cron tasks, that substitution
+# is done by `taskcluster/taskgraph/cron/decision.py`.  If you change any of the
+# template parameters, please do so in all three places!
 #
+# Available template parameters:
+#
+# - now:            current time
 # - owner:          push user (email address)
 # - source:         URL of this YAML file
 # - url:            repository URL
 # - project:        alias for the destination repository (basename of
 #                   the repo url)
 # - level:          SCM level of the destination repository
 #                   (1 = try, 3 = core)
-# - revision:       (short) hg revision of the head of the push
-# - revision_hash:  (long) hg revision of the head of the push
+# - revision:       hg revision of the head of the push
 # - comment:        comment of the push
 # - pushlog_id:     id in the pushlog table of the repository
 #
 # and functions:
 # - as_slugid:      convert a label into a slugId
 # - from_now:       generate a timestamp at a fixed offset from now
+# - shellquote:     quote the contents for injection into shell
 
 # The resulting tasks' taskGroupId will be equal to the taskId of the first
 # task listed here, which should be the decision task.  This gives other tools
 # an easy way to determine the ID of the decision task that created a
 # particular group.
 
 tasks:
   - taskId: '{{#as_slugid}}decision task{{/as_slugid}}'
     task:
       created: '{{now}}'
       deadline: '{{#from_now}}1 day{{/from_now}}'
       expires: '{{#from_now}}365 day{{/from_now}}'
       metadata:
-        owner: mozilla-taskcluster-maintenance@mozilla.com
+        owner: {{owner}}
         source: {{{source}}}
         name: "Gecko Decision Task"
         description: |
             The task that creates all of the other tasks in the task graph
 
       workerType: "gecko-decision"
       provisionerId: "aws-provisioner-v1"
 
@@ -100,17 +106,16 @@ tasks:
               --project='{{project}}'
               --message={{#shellquote}}{{{comment}}}{{/shellquote}}
               --owner='{{owner}}'
               --level='{{level}}'
               --base-repository='https://hg.mozilla.org/mozilla-central'
               --head-repository='{{{url}}}'
               --head-ref='{{revision}}'
               --head-rev='{{revision}}'
-              --revision-hash='{{revision_hash}}'
 
         artifacts:
           'public':
             type: 'directory'
             path: '/home/worker/artifacts'
             expires: '{{#from_now}}364 days{{/from_now}}'
 
       extra:
--- a/b2g/components/test/unit/xpcshell.ini
+++ b/b2g/components/test/unit/xpcshell.ini
@@ -1,23 +1,21 @@
 [DEFAULT]
 head =
-tail =
 
 support-files =
   data/test_logger_file
 
 [test_bug793310.js]
 
 [test_bug832946.js]
 
 [test_fxaccounts.js]
 [test_signintowebsite.js]
 head = head_identity.js
-tail =
 
 # testing non gonk-specific stuff
 [test_logcapture.js]
 
 [test_logcapture_gonk.js]
 # can be slow because of what the test does, so let's give it some more time
 # to avoid intermittents: bug 1212395
 requesttimeoutfactor = 2
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -169,17 +169,17 @@ pref("extensions.dss.enabled", false);  
 pref("extensions.dss.switchPending", false);    // Non-dynamic switch pending after next
                                                 // restart.
 
 pref("extensions.{972ce4c6-7e08-4474-a285-3208198ce6fd}.name", "chrome://browser/locale/browser.properties");
 pref("extensions.{972ce4c6-7e08-4474-a285-3208198ce6fd}.description", "chrome://browser/locale/browser.properties");
 
 pref("lightweightThemes.update.enabled", true);
 pref("lightweightThemes.getMoreURL", "https://addons.mozilla.org/%LOCALE%/firefox/themes");
-pref("lightweightThemes.recommendedThemes", "[{\"id\":\"recommended-1\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/a-web-browser-renaissance/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.header.jpg\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.footer.jpg\",\"textcolor\":\"#000000\",\"accentcolor\":\"#f2d9b1\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.icon.jpg\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.preview.jpg\",\"author\":\"Sean.Martell\",\"version\":\"0\"},{\"id\":\"recommended-2\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/space-fantasy/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.header.jpg\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.footer.jpg\",\"textcolor\":\"#ffffff\",\"accentcolor\":\"#d9d9d9\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.icon.jpg\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.preview.jpg\",\"author\":\"fx5800p\",\"version\":\"1.0\"},{\"id\":\"recommended-3\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/linen-light/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/3.header.png\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/3.footer.png\",\"accentcolor\":\"#ada8a8\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/3.icon.png\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/3.preview.png\",\"author\":\"DVemer\",\"version\":\"1.0\"},{\"id\":\"recommended-4\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/pastel-gradient/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.header.png\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.footer.png\",\"textcolor\":\"#000000\",\"accentcolor\":\"#000000\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.icon.png\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.preview.png\",\"author\":\"darrinhenein\",\"version\":\"1.0\"},{\"id\":\"recommended-5\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/carbon-light/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/5.header.png\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/5.footer.png\",\"textcolor\":\"#3b3b3b\",\"accentcolor\":\"#2e2e2e\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/5.icon.jpg\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/5.preview.jpg\",\"author\":\"Jaxivo\",\"version\":\"1.0\"}]");
+pref("lightweightThemes.recommendedThemes", "[{\"id\":\"recommended-1\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/a-web-browser-renaissance/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.header.jpg\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.footer.jpg\",\"textcolor\":\"#000000\",\"accentcolor\":\"#f2d9b1\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.icon.jpg\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/1.preview.jpg\",\"author\":\"Sean.Martell\",\"version\":\"0\"},{\"id\":\"recommended-2\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/space-fantasy/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.header.jpg\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.footer.jpg\",\"textcolor\":\"#ffffff\",\"accentcolor\":\"#d9d9d9\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.icon.jpg\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/2.preview.jpg\",\"author\":\"fx5800p\",\"version\":\"1.0\"},{\"id\":\"recommended-4\",\"homepageURL\":\"https://addons.mozilla.org/firefox/addon/pastel-gradient/\",\"headerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.header.png\",\"footerURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.footer.png\",\"textcolor\":\"#000000\",\"accentcolor\":\"#000000\",\"iconURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.icon.png\",\"previewURL\":\"resource:///chrome/browser/content/browser/defaultthemes/4.preview.png\",\"author\":\"darrinhenein\",\"version\":\"1.0\"}]");
 
 #if defined(MOZ_WIDEVINE_EME)
 pref("browser.eme.ui.enabled", true);
 #else
 pref("browser.eme.ui.enabled", false);
 #endif
 
 // UI tour experience.
--- a/browser/base/content/aboutTabCrashed.xhtml
+++ b/browser/base/content/aboutTabCrashed.xhtml
@@ -71,17 +71,17 @@
             <input type="text" id="email" placeholder="&tabCrashed.emailPlaceholder;"/>
           </li>
         </ul>
 
         <div id="requestAutoSubmit" hidden="true">
           <h2>&tabCrashed.requestAutoSubmit2;</h2>
           <div class="checkbox-with-label">
             <input type="checkbox" id="autoSubmit"/>
-            <label for="autoSubmit">&tabCrashed.autoSubmit;</label>
+            <label for="autoSubmit">&tabCrashed.autoSubmit2;</label>
           </div>
         </div>
       </div>
 
       <p id="reportSent">&tabCrashed.reportSent;</p>
 
       <div class="button-container">
         <button id="closeTab">
--- a/browser/base/content/browser-addons.js
+++ b/browser/base/content/browser-addons.js
@@ -233,39 +233,49 @@ const gXPInstallObserver = {
       persistent: true,
       hideClose: true,
       timeout: Date.now() + 30000,
     };
 
     switch (aTopic) {
     case "addon-install-disabled": {
       notificationID = "xpinstall-disabled";
+      let secondaryActions = null;
 
       if (gPrefService.prefIsLocked("xpinstall.enabled")) {
         messageString = gNavigatorBundle.getString("xpinstallDisabledMessageLocked");
         buttons = [];
       } else {
         messageString = gNavigatorBundle.getString("xpinstallDisabledMessage");
 
         action = {
           label: gNavigatorBundle.getString("xpinstallDisabledButton"),
           accessKey: gNavigatorBundle.getString("xpinstallDisabledButton.accesskey"),
           callback: function editPrefs() {
             gPrefService.setBoolPref("xpinstall.enabled", true);
           }
         };
+
+        secondaryActions = [{
+          label: gNavigatorBundle.getString("addonInstall.cancelButton.label"),
+          accessKey: gNavigatorBundle.getString("addonInstall.cancelButton.accesskey"),
+          callback: () => {},
+        }];
       }
 
       PopupNotifications.show(browser, notificationID, messageString, anchorID,
-                              action, null, options);
+                              action, secondaryActions, options);
       break; }
     case "addon-install-origin-blocked": {
       messageString = gNavigatorBundle.getFormattedString("xpinstallPromptMessage",
                         [brandShortName]);
 
+      options.removeOnDismissal = true;
+      options.persistent = false;
+
       let secHistogram = Components.classes["@mozilla.org/base/telemetry;1"].getService(Ci.nsITelemetry).getHistogramById("SECURITY_UI");
       secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED);
       let popup = PopupNotifications.show(browser, notificationID,
                                           messageString, anchorID,
                                           null, null, options);
       removeNotificationOnEnd(popup, installInfo.installs);
       break; }
     case "addon-install-blocked": {
@@ -345,16 +355,19 @@ const gXPInstallObserver = {
       };
       let notification = PopupNotifications.show(browser, notificationID, messageString,
                                                  anchorID, action,
                                                  [secondaryAction], options);
       notification._startTime = Date.now();
 
       break; }
     case "addon-install-failed": {
+      options.removeOnDismissal = true;
+      options.persistent = false;
+
       // TODO This isn't terribly ideal for the multiple failure case
       for (let install of installInfo.installs) {
         let host;
         try {
           host  = options.displayURI.host;
         } catch (e) {
           // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
         }
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -4938,18 +4938,18 @@ var TabsProgressListener = {
     // for this window has already been initialized (i.e. its getter no
     // longer exists)
     if (!Object.getOwnPropertyDescriptor(window, "PopupNotifications").get)
       PopupNotifications.locationChange(aBrowser);
 
     let tab = gBrowser.getTabForBrowser(aBrowser);
     if (tab && tab._sharingState) {
       gBrowser.setBrowserSharing(aBrowser, {});
-      webrtcUI.forgetStreamsFromBrowser(aBrowser);
-    }
+    }
+    webrtcUI.forgetStreamsFromBrowser(aBrowser);
 
     gBrowser.getNotificationBox(aBrowser).removeTransientNotifications();
 
     FullZoom.onLocationChange(aLocationURI, false, aBrowser);
   },
 }
 
 function nsBrowserAccess() { }
@@ -6677,25 +6677,25 @@ var gIdentityHandler = {
     return this._identityBox = document.getElementById("identity-box");
   },
   get _identityPopupMultiView() {
     delete this._identityPopupMultiView;
     return this._identityPopupMultiView = document.getElementById("identity-popup-multiView");
   },
   get _identityPopupContentHosts() {
     delete this._identityPopupContentHosts;
-    let selector = ".identity-popup-headline.host";
+    let selector = ".identity-popup-host";
     return this._identityPopupContentHosts = [
       ...this._identityPopupMultiView._mainView.querySelectorAll(selector),
       ...document.querySelectorAll(selector)
     ];
   },
   get _identityPopupContentHostless() {
     delete this._identityPopupContentHostless;
-    let selector = ".identity-popup-headline.hostless";
+    let selector = ".identity-popup-hostless";
     return this._identityPopupContentHostless = [
       ...this._identityPopupMultiView._mainView.querySelectorAll(selector),
       ...document.querySelectorAll(selector)
     ];
   },
   get _identityPopupContentOwner() {
     delete this._identityPopupContentOwner;
     return this._identityPopupContentOwner =
@@ -7507,16 +7507,17 @@ var gIdentityHandler = {
                     perm.scope == SitePermissions.SCOPE_PERSISTENT) {
                   SitePermissions.remove(uri, id);
                 }
               }
             }
           }
         }
         browser.messageManager.sendAsyncMessage("webrtc:StopSharing", windowId);
+        webrtcUI.forgetActivePermissionsFromBrowser(gBrowser.selectedBrowser);
       }
       SitePermissions.remove(gBrowser.currentURI, aPermission.id, browser);
 
       this._permissionReloadHint.removeAttribute("hidden");
 
       // Set telemetry values for clearing a permission
       let histogram = Services.telemetry.getKeyedHistogramById("WEB_PERMISSION_CLEARED");
 
--- a/browser/base/content/test/webrtc/browser.ini
+++ b/browser/base/content/test/webrtc/browser.ini
@@ -1,14 +1,19 @@
 [DEFAULT]
 support-files =
   get_user_media.html
+  get_user_media_in_frame.html
   get_user_media_content_script.js
   head.js
 
 [browser_devices_get_user_media.js]
 skip-if = (os == "linux" && debug) # linux: bug 976544
 [browser_devices_get_user_media_anim.js]
 [browser_devices_get_user_media_in_frame.js]
 [browser_devices_get_user_media_screen.js]
 skip-if = (e10s && debug) || (os == "linux" && !debug) # bug 1320754 for e10s debug, and bug 1320994 for linux opt
 [browser_devices_get_user_media_tear_off_tab.js]
+[browser_devices_get_user_media_unprompted_access.js]
+[browser_devices_get_user_media_unprompted_access_in_frame.js]
+[browser_devices_get_user_media_unprompted_access_tear_off_tab.js]
+skip-if = (os == "linux") # linux: bug 1331616
 [browser_webrtc_hooks.js]
--- a/browser/base/content/test/webrtc/browser_devices_get_user_media.js
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media.js
@@ -170,16 +170,34 @@ var gTests = [
 
     yield indicator;
     yield checkSharingUI({video: true, audio: true});
 
     yield stopSharing();
 
     // the stream is already closed, but this will do some cleanup anyway
     yield closeStream(true);
+
+    // After stop sharing, gUM(audio+camera) causes a prompt.
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+    yield checkNotSharing();
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
   }
 },
 
 {
   desc: "getUserMedia audio+video: reloading the page removes all gUM UI",
   run: function* checkReloading() {
     let promise = promisePopupNotificationShown("webRTC-shareDevices");
     yield promiseRequestDevice(true, true);
@@ -195,16 +213,34 @@ var gTests = [
     yield expectObserverCalled("recording-device-events");
     Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
                      "expected camera and microphone to be shared");
 
     yield indicator;
     yield checkSharingUI({video: true, audio: true});
 
     yield reloadAndAssertClosedStreams();
+
+    // After the reload, gUM(audio+camera) causes a prompt.
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+    yield checkNotSharing();
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
   }
 },
 
 {
   desc: "getUserMedia prompt: Always/Never Share",
   run: function* checkRememberCheckbox() {
     let elt = id => document.getElementById(id);
 
--- a/browser/base/content/test/webrtc/browser_devices_get_user_media_in_frame.js
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_in_frame.js
@@ -1,27 +1,16 @@
 /* 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/. */
 
 registerCleanupFunction(function() {
   gBrowser.removeCurrentTab();
 });
 
-function promiseReloadFrame(aFrameId) {
-  return ContentTask.spawn(gBrowser.selectedBrowser, aFrameId, function*(contentFrameId) {
-    content.wrappedJSObject
-           .document
-           .getElementById(contentFrameId)
-           .contentWindow
-           .location
-           .reload();
-  });
-}
-
 var gTests = [
 
 {
   desc: "getUserMedia audio+video",
   run: function* checkAudioVideo() {
     let promise = promisePopupNotificationShown("webRTC-shareDevices");
     yield promiseRequestDevice(true, true, "frame1");
     yield promise;
@@ -106,20 +95,21 @@ var gTests = [
     yield expectObserverCalled("recording-device-events");
     Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
                      "expected camera and microphone to be shared");
 
     yield indicator;
     yield checkSharingUI({video: true, audio: true});
 
     info("reloading the frame");
-    promise = promiseObserverCalled("recording-device-events");
+    promise = promiseObserverCalled("recording-device-stopped");
     yield promiseReloadFrame("frame1");
     yield promise;
 
+    yield expectObserverCalled("recording-device-events");
     yield expectObserverCalled("recording-window-ended");
     yield expectNoObserverCalled();
     yield checkNotSharing();
   }
 },
 
 {
   desc: "getUserMedia audio+video: reloading the frame removes prompts",
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access.js
@@ -0,0 +1,291 @@
+/* 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/. */
+
+registerCleanupFunction(function() {
+  gBrowser.removeCurrentTab();
+});
+
+const permissionError = "error: NotAllowedError: The request is not allowed " +
+    "by the user agent or the platform in the current context.";
+
+var gTests = [
+
+{
+  desc: "getUserMedia audio+camera",
+  run: function* checkAudioVideoWhileLiveTracksExist_audio_camera() {
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    let indicator = promiseIndicatorWindow();
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
+                     "expected camera and microphone to be shared");
+    yield indicator;
+    yield checkSharingUI({audio: true, video: true});
+
+    // If there's an active audio+camera stream,
+    // gUM(audio+camera) returns a stream without prompting;
+    promise = promiseMessage("ok");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    yield promiseNoPopupNotification("webRTC-shareDevices");
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
+                     "expected camera and microphone to be shared");
+
+    yield checkSharingUI({audio: true, video: true});
+
+    // gUM(screen) causes a prompt.
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(false, true, null, "screen");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+
+    is(PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+       "webRTC-shareScreen-notification-icon", "anchored to device icon");
+    checkDeviceSelectors(false, false, true);
+
+    yield promiseMessage(permissionError, () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+
+    // After closing all streams, gUM(audio+camera) causes a prompt.
+    yield closeStream(false, 0, 2);
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+    yield checkNotSharing();
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+  }
+},
+
+{
+  desc: "getUserMedia camera",
+  run: function* checkAudioVideoWhileLiveTracksExist_camera() {
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(false, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    let indicator = promiseIndicatorWindow();
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {video: true},
+                     "expected camera to be shared");
+    yield indicator;
+    yield checkSharingUI({audio: false, video: true});
+
+    // If there's an active camera stream,
+    // gUM(audio) causes a prompt;
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, false);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, false);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+
+    // gUM(audio+camera) causes a prompt;
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+
+    // gUM(screen) causes a prompt;
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(false, true, null, "screen");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+
+    is(PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+       "webRTC-shareScreen-notification-icon", "anchored to device icon");
+    checkDeviceSelectors(false, false, true);
+
+    yield promiseMessage(permissionError, () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+
+    // gUM(camera) returns a stream without prompting.
+    promise = promiseMessage("ok");
+    yield promiseRequestDevice(false, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    yield promiseNoPopupNotification("webRTC-shareDevices");
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+
+    Assert.deepEqual((yield getMediaCaptureState()), {video: true},
+                     "expected camera to be shared");
+
+    yield checkSharingUI({audio: false, video: true});
+
+    // close all streams
+    yield closeStream(false, 0, 2);
+  }
+},
+
+{
+  desc: "getUserMedia audio",
+  run: function* checkAudioVideoWhileLiveTracksExist_audio() {
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, false);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    let indicator = promiseIndicatorWindow();
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true},
+                     "expected microphone to be shared");
+    yield indicator;
+    yield checkSharingUI({audio: true, video: false});
+
+    // If there's an active audio stream,
+    // gUM(camera) causes a prompt;
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(false, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(false, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+
+    // gUM(audio+camera) causes a prompt;
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+
+    // gUM(audio) returns a stream without prompting.
+    promise = promiseMessage("ok");
+    yield promiseRequestDevice(true, false);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    yield promiseNoPopupNotification("webRTC-shareDevices");
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true},
+                     "expected microphone to be shared");
+
+    yield checkSharingUI({audio: true, video: false});
+
+    // close all streams
+    yield closeStream(false, 0, 2);
+  }
+}
+
+];
+
+function test() {
+  waitForExplicitFinish();
+
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
+  let browser = tab.linkedBrowser;
+
+  browser.messageManager.loadFrameScript(CONTENT_SCRIPT_HELPER, true);
+
+  browser.addEventListener("load", function onload() {
+    browser.removeEventListener("load", onload, true);
+
+    is(PopupNotifications._currentNotifications.length, 0,
+       "should start the test without any prior popup notification");
+    ok(gIdentityHandler._identityPopup.hidden,
+       "should start the test with the control center hidden");
+
+    Task.spawn(function* () {
+      yield SpecialPowers.pushPrefEnv({"set": [[PREF_PERMISSION_FAKE, true]]});
+
+      for (let testCase of gTests) {
+        info(testCase.desc);
+        yield testCase.run();
+
+        // Cleanup before the next test
+        yield expectNoObserverCalled();
+      }
+    }).then(finish, ex => {
+     Cu.reportError(ex);
+     ok(false, "Unexpected Exception: " + ex);
+     finish();
+    });
+  }, true);
+  let rootDir = getRootDirectory(gTestPath);
+  rootDir = rootDir.replace("chrome://mochitests/content/",
+                            "https://example.com/");
+  content.location = rootDir + "get_user_media.html";
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_in_frame.js
@@ -0,0 +1,248 @@
+/* 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/. */
+
+registerCleanupFunction(function() {
+  gBrowser.removeCurrentTab();
+});
+
+const permissionError = "error: NotAllowedError: The request is not allowed " +
+    "by the user agent or the platform in the current context.";
+
+var gTests = [
+
+{
+  desc: "getUserMedia audio+camera in frame 1",
+  run: function* checkAudioVideoWhileLiveTracksExist_frame() {
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true, "frame1");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    let indicator = promiseIndicatorWindow();
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
+                     "expected camera and microphone to be shared");
+
+    yield indicator;
+    yield checkSharingUI({video: true, audio: true});
+    yield expectNoObserverCalled();
+
+    info("gUM(audio+camera) in frame 2 should prompt");
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true, "frame2");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+
+    // If there's an active audio+camera stream in frame 1,
+    // gUM(audio+camera) in frame 1 returns a stream without prompting;
+    promise = promiseMessage("ok");
+    yield promiseRequestDevice(true, true, "frame1");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    yield promiseNoPopupNotification("webRTC-shareDevices");
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+
+    // close the stream
+    yield closeStream(false, "frame1", 2);
+  }
+},
+
+{
+  desc: "getUserMedia audio+camera in frame 1 - part II",
+  run: function* checkAudioVideoWhileLiveTracksExist_frame_partII() {
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true, "frame1");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    let indicator = promiseIndicatorWindow();
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
+                     "expected camera and microphone to be shared");
+
+    yield indicator;
+    yield checkSharingUI({video: true, audio: true});
+    yield expectNoObserverCalled();
+
+    // If there's an active audio+camera stream in frame 1,
+    // gUM(audio+camera) in the top level window causes a prompt;
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+
+    // close the stream
+    yield closeStream(false, "frame1");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+  }
+},
+
+{
+  desc: "getUserMedia audio+camera in frame 1 - reload",
+  run: function* checkAudioVideoWhileLiveTracksExist_frame_reload() {
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true, "frame1");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    let indicator = promiseIndicatorWindow();
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
+                     "expected camera and microphone to be shared");
+
+    yield indicator;
+    yield checkSharingUI({video: true, audio: true});
+    yield expectNoObserverCalled();
+
+    // reload frame 1
+    promise = promiseObserverCalled("recording-device-stopped");
+    yield promiseReloadFrame("frame1");
+    yield promise;
+
+    yield checkNotSharing();
+    yield expectObserverCalled("recording-device-events");
+    yield expectObserverCalled("recording-window-ended");
+    yield expectNoObserverCalled();
+
+    // After the reload,
+    // gUM(audio+camera) in frame 1 causes a prompt.
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true, "frame1");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+  }
+},
+
+{
+  desc: "getUserMedia audio+camera at the top level window",
+  run: function* checkAudioVideoWhileLiveTracksExist_topLevel() {
+    // create an active audio+camera stream at the top level window
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+    let indicator = promiseIndicatorWindow();
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
+                     "expected camera and microphone to be shared");
+
+    yield indicator;
+    yield checkSharingUI({audio: true, video: true});
+
+    // If there's an active audio+camera stream at the top level window,
+    // gUM(audio+camera) in frame 2 causes a prompt.
+    promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true, "frame2");
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage(permissionError, () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+
+    // close the stream
+    yield closeStream(false);
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "microphone", gBrowser.selectedBrowser);
+  }
+}
+
+];
+
+function test() {
+  waitForExplicitFinish();
+
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
+  let browser = tab.linkedBrowser;
+
+  browser.messageManager.loadFrameScript(CONTENT_SCRIPT_HELPER, true);
+
+  browser.addEventListener("load", function onload() {
+    browser.removeEventListener("load", onload, true);
+
+    is(PopupNotifications._currentNotifications.length, 0,
+       "should start the test without any prior popup notification");
+
+    Task.spawn(function* () {
+      yield SpecialPowers.pushPrefEnv({"set": [[PREF_PERMISSION_FAKE, true]]});
+
+      for (let testCase of gTests) {
+        info(testCase.desc);
+        yield testCase.run();
+
+        // Cleanup before the next test
+        yield expectNoObserverCalled();
+      }
+    }).then(finish, ex => {
+     Cu.reportError(ex);
+     ok(false, "Unexpected Exception: " + ex);
+     finish();
+    });
+  }, true);
+  let rootDir = getRootDirectory(gTestPath);
+  rootDir = rootDir.replace("chrome://mochitests/content/",
+                            "https://example.com/");
+  content.location = rootDir + "get_user_media_in_frame.html";
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_tear_off_tab.js
@@ -0,0 +1,102 @@
+/* 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/. */
+
+registerCleanupFunction(function() {
+  gBrowser.removeCurrentTab();
+});
+
+var gTests = [
+
+{
+  desc: "getUserMedia: tearing-off a tab",
+  run: function* checkAudioVideoWhileLiveTracksExist_TearingOff() {
+    let promise = promisePopupNotificationShown("webRTC-shareDevices");
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    let indicator = promiseIndicatorWindow();
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+    Assert.deepEqual((yield getMediaCaptureState()), {audio: true, video: true},
+                     "expected camera and microphone to be shared");
+
+    yield indicator;
+    yield checkSharingUI({video: true, audio: true});
+
+    info("tearing off the tab");
+    let win = gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+    yield whenDelayedStartupFinished(win);
+    yield checkSharingUI({audio: true, video: true}, win);
+
+    gBrowser.selectedBrowser.messageManager.loadFrameScript(CONTENT_SCRIPT_HELPER, true);
+
+    info("request audio+video and check if there is no prompt");
+    yield promiseRequestDevice(true, true, null, null, win.gBrowser.selectedBrowser);
+    yield promiseObserverCalled("getUserMedia:request");
+    yield promiseNoPopupNotification("webRTC-shareDevices");
+    yield expectObserverCalled("getUserMedia:response:allow");
+    yield expectObserverCalled("recording-device-events");
+
+    let promises = [promiseObserverCalled("recording-device-events"),
+                    promiseObserverCalled("recording-device-events"),
+                    promiseObserverCalled("recording-window-ended")];
+    yield BrowserTestUtils.closeWindow(win);
+    yield Promise.all(promises);
+
+    yield checkNotSharing();
+  }
+}
+
+];
+
+function test() {
+  waitForExplicitFinish();
+  SpecialPowers.pushPrefEnv({"set": [["dom.ipc.processCount", 1]]}, runTest);
+}
+
+function runTest() {
+  // An empty tab where we can load the content script without leaving it
+  // behind at the end of the test.
+  gBrowser.addTab();
+
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
+  let browser = tab.linkedBrowser;
+
+  browser.messageManager.loadFrameScript(CONTENT_SCRIPT_HELPER, true);
+
+  browser.addEventListener("load", function onload() {
+    browser.removeEventListener("load", onload, true);
+
+    is(PopupNotifications._currentNotifications.length, 0,
+       "should start the test without any prior popup notification");
+    ok(gIdentityHandler._identityPopup.hidden,
+       "should start the test with the control center hidden");
+
+    Task.spawn(function* () {
+      yield SpecialPowers.pushPrefEnv({"set": [[PREF_PERMISSION_FAKE, true]]});
+
+      for (let testCase of gTests) {
+        info(testCase.desc);
+        yield testCase.run();
+
+        // Cleanup before the next test
+        yield expectNoObserverCalled();
+      }
+    }).then(finish, ex => {
+     Cu.reportError(ex);
+     ok(false, "Unexpected Exception: " + ex);
+     finish();
+    });
+  }, true);
+  let rootDir = getRootDirectory(gTestPath);
+  rootDir = rootDir.replace("chrome://mochitests/content/",
+                            "https://example.com/");
+  content.location = rootDir + "get_user_media.html";
+}
--- a/browser/base/content/test/webrtc/get_user_media.html
+++ b/browser/base/content/test/webrtc/get_user_media.html
@@ -17,39 +17,42 @@ try {
   useFakeStreams = true;
 }
 
 function message(m) {
   document.getElementById("message").innerHTML = m;
   window.parent.postMessage(m, "*");
 }
 
-var gStream;
+var gStreams = [];
 
 function requestDevice(aAudio, aVideo, aShare) {
   var opts = {video: aVideo, audio: aAudio};
   if (aShare) {
     opts.video = {
       mozMediaSource: aShare,
       mediaSource: aShare
     }
   } else if (useFakeStreams) {
     opts.fake = true;
   }
 
   window.navigator.mediaDevices.getUserMedia(opts)
     .then(stream => {
-      gStream = stream;
+      gStreams.push(stream);
       message("ok");
     }, err => message("error: " + err));
 }
 message("pending");
 
 function closeStream() {
-  if (!gStream)
-    return;
-  gStream.getTracks().forEach(t => t.stop());
-  gStream = null;
+  for (let stream of gStreams) {
+    if (stream) {
+      stream.getTracks().forEach(t => t.stop());
+      stream = null;
+    }
+  }
+  gStreams = [];
   message("closed");
 }
 </script>
 </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/webrtc/get_user_media_in_frame.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="message"></div>
+<script>
+// Specifies whether we are using fake streams to run this automation
+var useFakeStreams = true;
+try {
+  var audioDevice = SpecialPowers.getCharPref("media.audio_loopback_dev");
+  var videoDevice = SpecialPowers.getCharPref("media.video_loopback_dev");
+  dump("TEST DEVICES: Using media devices:\n");
+  dump("audio: " + audioDevice + "\nvideo: " + videoDevice + "\n");
+  useFakeStreams = false;
+} catch (e) {
+  dump("TEST DEVICES: No test devices found (in media.{audio,video}_loopback_dev, using fake streams.\n");
+  useFakeStreams = true;
+}
+
+function message(m) {
+  document.getElementById("message").innerHTML = m;
+  window.parent.postMessage(m, "*");
+}
+
+var gStreams = [];
+
+function requestDevice(aAudio, aVideo, aShare) {
+  var opts = {video: aVideo, audio: aAudio};
+  if (aShare) {
+    opts.video = {
+      mozMediaSource: aShare,
+      mediaSource: aShare
+    }
+  } else if (useFakeStreams) {
+    opts.fake = true;
+  }
+
+  window.navigator.mediaDevices.getUserMedia(opts)
+    .then(stream => {
+      gStreams.push(stream);
+      message("ok");
+    }, err => message("error: " + err));
+}
+message("pending");
+
+function closeStream() {
+  for (let stream of gStreams) {
+    if (stream) {
+      stream.getTracks().forEach(t => t.stop());
+      stream = null;
+    }
+  }
+  gStreams = [];
+  message("closed");
+}
+</script>
+<iframe id="frame1" src="get_user_media.html"></iframe>
+<iframe id="frame2" src="get_user_media.html"></iframe>
+</body>
+</html>
--- a/browser/base/content/test/webrtc/head.js
+++ b/browser/base/content/test/webrtc/head.js
@@ -366,35 +366,39 @@ function* stopSharing(aType = "camera", 
     yield expectObserverCalled("recording-window-ended");
 
   yield expectNoObserverCalled(aExpectDoubleRecordingEvent);
 
   if (!aShouldKeepSharing)
     yield* checkNotSharing();
 }
 
-function promiseRequestDevice(aRequestAudio, aRequestVideo, aFrameId, aType) {
+function promiseRequestDevice(aRequestAudio, aRequestVideo, aFrameId, aType,
+                              aBrowser = gBrowser.selectedBrowser) {
   info("requesting devices");
-  return ContentTask.spawn(gBrowser.selectedBrowser,
+  return ContentTask.spawn(aBrowser,
                            {aRequestAudio, aRequestVideo, aFrameId, aType},
                            function*(args) {
     let global = content.wrappedJSObject;
     if (args.aFrameId)
       global = global.document.getElementById(args.aFrameId).contentWindow;
     global.requestDevice(args.aRequestAudio, args.aRequestVideo, args.aType);
   });
 }
 
-function* closeStream(aAlreadyClosed, aFrameId) {
+function* closeStream(aAlreadyClosed, aFrameId, aStreamCount = 1) {
   yield expectNoObserverCalled();
 
   let promises;
   if (!aAlreadyClosed) {
-    promises = [promiseObserverCalled("recording-device-events"),
-                promiseObserverCalled("recording-window-ended")];
+    promises = [];
+    for (let i = 0; i < aStreamCount; i++) {
+      promises.push(promiseObserverCalled("recording-device-events"));
+    }
+    promises.push(promiseObserverCalled("recording-window-ended"));
   }
 
   info("closing the stream");
   yield ContentTask.spawn(gBrowser.selectedBrowser, aFrameId, function*(contentFrameId) {
     let global = content.wrappedJSObject;
     if (contentFrameId)
       global = global.document.getElementById(contentFrameId).contentWindow;
     global.closeStream();
@@ -489,8 +493,19 @@ function* checkNotSharing() {
   Assert.deepEqual((yield getMediaCaptureState()), {},
                    "expected nothing to be shared");
 
   ok(!document.getElementById("identity-box").hasAttribute("sharing"),
      "no sharing indicator on the control center icon");
 
   yield* assertWebRTCIndicatorStatus(null);
 }
+
+function promiseReloadFrame(aFrameId) {
+  return ContentTask.spawn(gBrowser.selectedBrowser, aFrameId, function*(contentFrameId) {
+    content.wrappedJSObject
+           .document
+           .getElementById(contentFrameId)
+           .contentWindow
+           .location
+           .reload();
+  });
+}
--- a/browser/components/controlcenter/content/panel.inc.xul
+++ b/browser/components/controlcenter/content/panel.inc.xul
@@ -17,18 +17,18 @@
   <panelmultiview id="identity-popup-multiView"
                   mainViewId="identity-popup-mainView">
     <panelview id="identity-popup-mainView" flex="1">
 
       <!-- Security Section -->
       <hbox id="identity-popup-security" class="identity-popup-section">
         <vbox id="identity-popup-security-content" flex="1">
           <label class="plain">
-            <label class="identity-popup-headline host"></label>
-            <label class="identity-popup-headline hostless" crop="end"/>
+            <label class="identity-popup-headline identity-popup-host"></label>
+            <label class="identity-popup-headline identity-popup-hostless" crop="end"/>
           </label>
           <description class="identity-popup-connection-not-secure"
                        when-connection="not-secure secure-cert-user-overridden">&identity.connectionNotSecure;</description>
           <description class="identity-popup-connection-secure"
                        when-connection="secure secure-ev">&identity.connectionSecure;</description>
           <description when-connection="chrome">&identity.connectionInternal;</description>
           <description when-connection="file">&identity.connectionFile;</description>
 
@@ -92,18 +92,18 @@
         </vbox>
       </hbox>
     </panelview>
 
     <!-- Security SubView -->
     <panelview id="identity-popup-securityView" flex="1">
       <vbox id="identity-popup-securityView-header">
         <label class="plain">
-          <label class="identity-popup-headline host"></label>
-          <label class="identity-popup-headline hostless" crop="end"/>
+          <label class="identity-popup-headline identity-popup-host"></label>
+          <label class="identity-popup-headline identity-popup-hostless" crop="end"/>
         </label>
         <description class="identity-popup-connection-not-secure"
                      when-connection="not-secure secure-cert-user-overridden">&identity.connectionNotSecure;</description>
         <description class="identity-popup-connection-secure"
                      when-connection="secure secure-ev">&identity.connectionSecure;</description>
       </vbox>
 
       <vbox id="identity-popup-securityView-body" flex="1">
--- a/browser/components/downloads/test/unit/xpcshell.ini
+++ b/browser/components/downloads/test/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 head = head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_DownloadsCommon.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-c-devtools-inspectedWindow.js
@@ -0,0 +1,22 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+extensions.registerSchemaAPI("devtools.inspectedWindow", "devtools_child", context => {
+  // `devtoolsToolboxInfo` is received from the child process when the root devtools view
+  // has been created, and every sub-frame of that top level devtools frame will
+  // receive the same information when the context has been created from the
+  // `ExtensionChild.createExtensionContext` method.
+  let tabId = (context.devtoolsToolboxInfo &&
+               context.devtoolsToolboxInfo.inspectedWindowTabId);
+
+  return {
+    devtools: {
+      inspectedWindow: {
+        get tabId() {
+          return tabId;
+        },
+      },
+    },
+  };
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-devtools.js
@@ -0,0 +1,299 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* global getTargetTabIdForToolbox */
+
+/**
+ * This module provides helpers used by the other specialized `ext-devtools-*.js` modules
+ * and the implementation of the `devtools_page`.
+ */
+
+XPCOMUtils.defineLazyModuleGetter(this, "gDevTools",
+                                  "resource://devtools/client/framework/gDevTools.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+
+Cu.import("resource://gre/modules/ExtensionParent.jsm");
+
+const {
+  HiddenExtensionPage,
+  watchExtensionProxyContextLoad,
+} = ExtensionParent;
+
+// Map[extension -> DevToolsPageDefinition]
+let devtoolsPageDefinitionMap = new Map();
+
+/**
+ * Retrieve the devtools target for the devtools extension proxy context
+ * (lazily cloned from the target of the toolbox associated to the context
+ * the first time that it is accessed).
+ *
+ * @param {DevToolsExtensionPageContextParent} context
+ *   A devtools extension proxy context.
+ *
+ * @returns {Promise<TabTarget>}
+ *   The cloned devtools target associated to the context.
+ */
+global.getDevToolsTargetForContext = (context) => {
+  return Task.spawn(function* asyncGetTabTarget() {
+    if (context.devToolsTarget) {
+      return context.devToolsTarget;
+    }
+
+    if (!context.devToolsToolbox || !context.devToolsToolbox.target) {
+      throw new Error("Unable to get a TabTarget for a context not associated to any toolbox");
+    }
+
+    if (!context.devToolsToolbox.target.isLocalTab) {
+      throw new Error("Unexpected target type: only local tabs are currently supported.");
+    }
+
+    const {TabTarget} = require("devtools/client/framework/target");
+
+    context.devToolsTarget = new TabTarget(context.devToolsToolbox.target.tab);
+    yield context.devToolsTarget.makeRemote();
+
+    return context.devToolsTarget;
+  });
+};
+
+/**
+ * Retrieve the devtools target for the devtools extension proxy context
+ * (lazily cloned from the target of the toolbox associated to the context
+ * the first time that it is accessed).
+ *
+ * @param {Toolbox} toolbox
+ *   A devtools toolbox instance.
+ *
+ * @returns {number}
+ *   The corresponding WebExtensions tabId.
+ */
+global.getTargetTabIdForToolbox = (toolbox) => {
+  let {target} = toolbox;
+
+  if (!target.isLocalTab) {
+    throw new Error("Unexpected target type: only local tabs are currently supported.");
+  }
+
+  let parentWindow = target.tab.linkedBrowser.ownerDocument.defaultView;
+  let tab = parentWindow.gBrowser.getTabForBrowser(target.tab.linkedBrowser);
+
+  return TabManager.getId(tab);
+};
+
+/**
+ * The DevToolsPage represents the "devtools_page" related to a particular
+ * Toolbox and WebExtension.
+ *
+ * The devtools_page contexts are invisible WebExtensions contexts, similar to the
+ * background page, associated to a single developer toolbox (e.g. If an add-on
+ * registers a devtools_page and the user opens 3 developer toolbox in 3 webpages,
+ * 3 devtools_page contexts will be created for that add-on).
+ *
+ * @param {Extension}              extension
+ *   The extension that owns the devtools_page.
+ * @param {Object}                 options
+ * @param {Toolbox}                options.toolbox
+ *   The developer toolbox instance related to this devtools_page.
+ * @param {string}                 options.url
+ *   The path to the devtools page html page relative to the extension base URL.
+ * @param {DevToolsPageDefinition} options.devToolsPageDefinition
+ *   The instance of the devToolsPageDefinition class related to this DevToolsPage.
+ */
+class DevToolsPage extends HiddenExtensionPage {
+  constructor(extension, options) {
+    super(extension, "devtools_page");
+
+    this.url = extension.baseURI.resolve(options.url);
+    this.toolbox = options.toolbox;
+    this.devToolsPageDefinition = options.devToolsPageDefinition;
+
+    this.unwatchExtensionProxyContextLoad = null;
+
+    this.waitForTopLevelContext = new Promise(resolve => {
+      this.resolveTopLevelContext = resolve;
+    });
+  }
+
+  build() {
+    return Task.spawn(function* () {
+      yield this.createBrowserElement();
+
+      // Listening to new proxy contexts.
+      this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad(this, context => {
+        // Keep track of the toolbox and target associated to the context, which is
+        // needed by the API methods implementation.
+        context.devToolsToolbox = this.toolbox;
+
+        if (!this.topLevelContext) {
+          this.topLevelContext = context;
+
+          // Ensure this devtools page is destroyed, when the top level context proxy is
+          // closed.
+          this.topLevelContext.callOnClose(this);
+
+          this.resolveTopLevelContext(context);
+        }
+      });
+
+      extensions.emit("extension-browser-inserted", this.browser, {
+        devtoolsToolboxInfo: {
+          inspectedWindowTabId: getTargetTabIdForToolbox(this.toolbox),
+        },
+      });
+
+      this.browser.loadURI(this.url);
+
+      yield this.waitForTopLevelContext;
+    }.bind(this));
+  }
+
+  close() {
+    if (this.closed) {
+      throw new Error("Unable to shutdown a closed DevToolsPage instance");
+    }
+
+    this.closed = true;
+
+    // Unregister the devtools page instance from the devtools page definition.
+    this.devToolsPageDefinition.forgetForTarget(this.toolbox.target);
+
+    // Unregister it from the resources to cleanup when the context has been closed.
+    if (this.topLevelContext) {
+      this.topLevelContext.forgetOnClose(this);
+    }
+
+    // Stop watching for any new proxy contexts from the devtools page.
+    if (this.unwatchExtensionProxyContextLoad) {
+      this.unwatchExtensionProxyContextLoad();
+      this.unwatchExtensionProxyContextLoad = null;
+    }
+
+    super.shutdown();
+  }
+}
+
+/**
+ * The DevToolsPageDefinitions class represents the "devtools_page" manifest property
+ * of a WebExtension.
+ *
+ * A DevToolsPageDefinition instance is created automatically when a WebExtension
+ * which contains the "devtools_page" manifest property has been loaded, and it is
+ * automatically destroyed when the related WebExtension has been unloaded,
+ * and so there will be at most one DevtoolsPageDefinition per add-on.
+ *
+ * Every time a developer tools toolbox is opened, the DevToolsPageDefinition creates
+ * and keep track of a DevToolsPage instance (which represents the actual devtools_page
+ * instance related to that particular toolbox).
+ *
+ * @param {Extension} extension
+ *   The extension that owns the devtools_page.
+ * @param {string}    url
+ *   The path to the devtools page html page relative to the extension base URL.
+ */
+class DevToolsPageDefinition {
+  constructor(extension, url) {
+    this.url = url;
+    this.extension = extension;
+
+    // Map[TabTarget -> DevToolsPage]
+    this.devtoolsPageForTarget = new Map();
+  }
+
+  buildForToolbox(toolbox) {
+    if (this.devtoolsPageForTarget.has(toolbox.target)) {
+      return Promise.reject(new Error("DevtoolsPage has been already created for this toolbox"));
+    }
+
+    const devtoolsPage = new DevToolsPage(this.extension, {
+      toolbox, url: this.url, devToolsPageDefinition: this,
+    });
+    this.devtoolsPageForTarget.set(toolbox.target, devtoolsPage);
+
+    return devtoolsPage.build();
+  }
+
+  shutdownForTarget(target) {
+    if (this.devtoolsPageForTarget.has(target)) {
+      const devtoolsPage = this.devtoolsPageForTarget.get(target);
+      devtoolsPage.close();
+
+      // `devtoolsPage.close()` should remove the instance from the map,
+      // raise an exception if it is still there.
+      if (this.devtoolsPageForTarget.has(target)) {
+        throw new Error(`Leaked DevToolsPage instance for target "${target.toString()}"`);
+      }
+    }
+  }
+
+  forgetForTarget(target) {
+    this.devtoolsPageForTarget.delete(target);
+  }
+
+  shutdown() {
+    for (let target of this.devtoolsPageForTarget.keys()) {
+      this.shutdownForTarget(target);
+    }
+
+    if (this.devtoolsPageForTarget.size > 0) {
+      throw new Error(
+        `Leaked ${this.devtoolsPageForTarget.size} DevToolsPage instances in devtoolsPageForTarget Map`
+      );
+    }
+  }
+}
+
+/* eslint-disable mozilla/balanced-listeners */
+
+// Create a devtools page context for a new opened toolbox,
+// based on the registered devtools_page definitions.
+gDevTools.on("toolbox-created", (evt, toolbox) => {
+  if (!toolbox.target.isLocalTab) {
+    // Only local tabs are currently supported (See Bug 1304378 for additional details
+    // related to remote targets support).
+    let msg = `Ignoring DevTools Toolbox for target "${toolbox.target.toString()}": ` +
+              `"${toolbox.target.name}" ("${toolbox.target.url}"). ` +
+              "Only local tab are currently supported by the WebExtensions DevTools API.";
+    let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(Ci.nsIScriptError);
+    scriptError.init(msg, null, null, null, null, Ci.nsIScriptError.warningFlag, "content javascript");
+    let consoleService = Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService);
+    consoleService.logMessage(scriptError);
+
+    return;
+  }
+
+  for (let devtoolsPage of devtoolsPageDefinitionMap.values()) {
+    devtoolsPage.buildForToolbox(toolbox);
+  }
+});
+
+// Destroy a devtools page context for a destroyed toolbox,
+// based on the registered devtools_page definitions.
+gDevTools.on("toolbox-destroy", (evt, target) => {
+  if (!target.isLocalTab) {
+    // Only local tabs are currently supported (See Bug 1304378 for additional details
+    // related to remote targets support).
+    return;
+  }
+
+  for (let devtoolsPageDefinition of devtoolsPageDefinitionMap.values()) {
+    devtoolsPageDefinition.shutdownForTarget(target);
+  }
+});
+
+// Create and register a new devtools_page definition as specified in the
+// "devtools_page" property in the extension manifest.
+extensions.on("manifest_devtools_page", (type, directive, extension, manifest) => {
+  let devtoolsPageDefinition = new DevToolsPageDefinition(extension, manifest[directive]);
+  devtoolsPageDefinitionMap.set(extension, devtoolsPageDefinition);
+});
+
+// Destroy the registered devtools_page definition on extension shutdown.
+extensions.on("shutdown", (type, extension) => {
+  if (devtoolsPageDefinitionMap.has(extension)) {
+    devtoolsPageDefinitionMap.get(extension).shutdown();
+    devtoolsPageDefinitionMap.delete(extension);
+  }
+});
+/* eslint-enable mozilla/balanced-listeners */
--- a/browser/components/extensions/extensions-browser.manifest
+++ b/browser/components/extensions/extensions-browser.manifest
@@ -1,35 +1,41 @@
 # scripts
 category webextension-scripts bookmarks chrome://browser/content/ext-bookmarks.js
 category webextension-scripts browserAction chrome://browser/content/ext-browserAction.js
 category webextension-scripts browsingData chrome://browser/content/ext-browsingData.js
 category webextension-scripts commands chrome://browser/content/ext-commands.js
 category webextension-scripts contextMenus chrome://browser/content/ext-contextMenus.js
 category webextension-scripts desktop-runtime chrome://browser/content/ext-desktop-runtime.js
+category webextension-scripts devtools chrome://browser/content/ext-devtools.js
 category webextension-scripts history chrome://browser/content/ext-history.js
 category webextension-scripts omnibox chrome://browser/content/ext-omnibox.js
 category webextension-scripts pageAction chrome://browser/content/ext-pageAction.js
 category webextension-scripts sessions chrome://browser/content/ext-sessions.js
 category webextension-scripts tabs chrome://browser/content/ext-tabs.js
 category webextension-scripts theme chrome://browser/content/ext-theme.js
 category webextension-scripts utils chrome://browser/content/ext-utils.js
 category webextension-scripts windows chrome://browser/content/ext-windows.js
 
+# scripts specific for devtools extension contexts.
+category webextension-scripts-devtools devtools-inspectedWindow chrome://browser/content/ext-c-devtools-inspectedWindow.js
+
 # scripts that must run in the same process as addon code.
 category webextension-scripts-addon contextMenus chrome://browser/content/ext-c-contextMenus.js
 category webextension-scripts-addon omnibox chrome://browser/content/ext-c-omnibox.js
 category webextension-scripts-addon tabs chrome://browser/content/ext-c-tabs.js
 
 # schemas
 category webextension-schemas bookmarks chrome://browser/content/schemas/bookmarks.json
 category webextension-schemas browser_action chrome://browser/content/schemas/browser_action.json
 category webextension-schemas browsing_data chrome://browser/content/schemas/browsing_data.json
 category webextension-schemas commands chrome://browser/content/schemas/commands.json
 category webextension-schemas context_menus chrome://browser/content/schemas/context_menus.json
 category webextension-schemas context_menus_internal chrome://browser/content/schemas/context_menus_internal.json
+category webextension-schemas devtools chrome://browser/content/schemas/devtools.json
+category webextension-schemas devtools_inspected_window chrome://browser/content/schemas/devtools_inspected_window.json
 category webextension-schemas history chrome://browser/content/schemas/history.json
 category webextension-schemas omnibox chrome://browser/content/schemas/omnibox.json
 category webextension-schemas page_action chrome://browser/content/schemas/page_action.json
 category webextension-schemas sessions chrome://browser/content/schemas/sessions.json
 category webextension-schemas tabs chrome://browser/content/schemas/tabs.json
 category webextension-schemas theme chrome://browser/content/schemas/theme.json
 category webextension-schemas windows chrome://browser/content/schemas/windows.json
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -13,19 +13,21 @@ browser.jar:
 #endif
     content/browser/extension.svg
     content/browser/ext-bookmarks.js
     content/browser/ext-browserAction.js
     content/browser/ext-browsingData.js
     content/browser/ext-commands.js
     content/browser/ext-contextMenus.js
     content/browser/ext-desktop-runtime.js
+    content/browser/ext-devtools.js
     content/browser/ext-history.js
     content/browser/ext-omnibox.js
     content/browser/ext-pageAction.js
     content/browser/ext-sessions.js
     content/browser/ext-tabs.js
     content/browser/ext-theme.js
     content/browser/ext-utils.js
     content/browser/ext-windows.js
     content/browser/ext-c-contextMenus.js
+    content/browser/ext-c-devtools-inspectedWindow.js
     content/browser/ext-c-omnibox.js
     content/browser/ext-c-tabs.js
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/devtools.json
@@ -0,0 +1,16 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "devtools_page": {
+            "$ref": "ExtensionURL",
+            "optional": true
+          }
+        }
+      }
+    ]
+  }
+]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/devtools_inspected_window.json
@@ -0,0 +1,273 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+[
+  {
+    "namespace": "devtools.inspectedWindow",
+    "allowedContexts": ["devtools", "devtools_only"],
+    "defaultContexts": ["devtools", "devtools_only"],
+    "description": "Use the <code>chrome.devtools.inspectedWindow</code> API to interact with the inspected window: obtain the tab ID for the inspected page, evaluate the code in the context of the inspected window, reload the page, or obtain the list of resources within the page.",
+    "nocompile": true,
+    "types": [
+      {
+        "id": "Resource",
+        "type": "object",
+        "description": "A resource within the inspected page, such as a document, a script, or an image.",
+        "properties": {
+          "url": {
+            "type": "string",
+            "description": "The URL of the resource."
+          }
+        },
+        "functions": [
+          {
+            "name": "getContent",
+            "unsupported": true,
+            "type": "function",
+            "async": "callback",
+            "description": "Gets the content of the resource.",
+            "parameters": [
+              {
+                "name": "callback",
+                "type": "function",
+                "description": "A function that receives resource content when the request completes.",
+                "parameters": [
+                  {
+                    "name": "content",
+                    "type": "string",
+                    "description": "Content of the resource (potentially encoded)."
+                  },
+                  {
+                    "name": "encoding",
+                    "type": "string",
+                    "description": "Empty if content is not encoded, encoding name otherwise. Currently, only base64 is supported."
+                  }
+                ]
+              }
+            ]
+          },
+          {
+            "name": "setContent",
+            "unsupported": true,
+            "type": "function",
+            "async": "callback",
+            "description": "Sets the content of the resource.",
+            "parameters": [
+              {
+                "name": "content",
+                "type": "string",
+                "description": "New content of the resource. Only resources with the text type are currently supported."
+              },
+              {
+                "name": "commit",
+                "type": "boolean",
+                "description": "True if the user has finished editing the resource, and the new content of the resource should be persisted; false if this is a minor change sent in progress of the user editing the resource."
+              },
+              {
+                "name": "callback",
+                "type": "function",
+                "description": "A function called upon request completion.",
+                "optional": true,
+                "parameters": [
+                  {
+                    "name": "error",
+                    "type": "object",
+                    "additionalProperties": {"type": "any"},
+                    "optional": true,
+                    "description": "Set to undefined if the resource content was set successfully; describes error otherwise."
+                  }
+                ]
+              }
+            ]
+          }
+        ]
+      }
+    ],
+    "properties": {
+      "tabId": {
+        "description": "The ID of the tab being inspected. This ID may be used with chrome.tabs.* API.",
+        "type": "integer"
+      }
+    },
+    "functions": [
+      {
+        "name": "eval",
+        "unsupported": true,
+        "type": "function",
+        "description": "Evaluates a JavaScript expression in the context of the main frame of the inspected page. The expression must evaluate to a JSON-compliant object, otherwise an exception is thrown. The eval function can report either a DevTools-side error or a JavaScript exception that occurs during evaluation. In either case, the <code>result</code> parameter of the callback is <code>undefined</code>. In the case of a DevTools-side error, the <code>isException</code> parameter is non-null and has <code>isError</code> set to true and <code>code</code> set to an error code. In the case of a JavaScript error, <code>isException</code> is set to true and <code>value</code> is set to the string value of thrown object.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "expression",
+            "type": "string",
+            "description": "An expression to evaluate."
+          },
+          {
+            "name": "options",
+            "type": "object",
+            "optional": true,
+            "description": "The options parameter can contain one or more options.",
+            "properties": {
+              "frameURL": {
+                "type": "string",
+                "unsupported": true,
+                "optional": true,
+                "description": "If specified, the expression is evaluated on the iframe whose URL matches the one specified. By default, the expression is evaluated in the top frame of the inspected page."
+              },
+              "useContentScriptContext": {
+                "type": "boolean",
+                "unsupported": true,
+                "optional": true,
+                "description": "Evaluate the expression in the context of the content script of the calling extension, provided that the content script is already injected into the inspected page. If not, the expression is not evaluated and the callback is invoked with the exception parameter set to an object that has the <code>isError</code> field set to true and the <code>code</code> field set to <code>E_NOTFOUND</code>."
+              },
+              "contextSecurityOrigin": {
+                "type": "string",
+                "unsupported": true,
+                "optional": true,
+                "description": "Evaluate the expression in the context of a content script of an extension that matches the specified origin. If given, contextSecurityOrigin overrides the 'true' setting on userContentScriptContext."
+              }
+            }
+          },
+          {
+            "name": "callback",
+            "type": "function",
+            "description": "A function called when evaluation completes.",
+            "optional": true,
+            "parameters": [
+              {
+                "name": "result",
+                "type": "object",
+                "additionalProperties": {"type": "any"},
+                "description": "The result of evaluation."
+              },
+              {
+                "name": "exceptionInfo",
+                "type": "object",
+                "description": "An object providing details if an exception occurred while evaluating the expression.",
+                "properties": {
+                  "isError": {
+                    "type": "boolean",
+                    "description": "Set if the error occurred on the DevTools side before the expression is evaluated."
+                  },
+                  "code": {
+                    "type": "string",
+                    "description": "Set if the error occurred on the DevTools side before the expression is evaluated."
+                  },
+                  "description": {
+                    "type": "string",
+                    "description": "Set if the error occurred on the DevTools side before the expression is evaluated."
+                  },
+                  "details": {
+                    "type": "array",
+                    "items": { "type": "any" },
+                    "description": "Set if the error occurred on the DevTools side before the expression is evaluated, contains the array of the values that may be substituted into the description string to provide more information about the cause of the error."
+                  },
+                  "isException": {
+                    "type": "boolean",
+                    "description": "Set if the evaluated code produces an unhandled exception."
+                  },
+                  "value": {
+                    "type": "string",
+                    "description": "Set if the evaluated code produces an unhandled exception."
+                  }
+                }
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "reload",
+        "unsupported": true,
+        "type": "function",
+        "description": "Reloads the inspected page.",
+        "parameters": [
+          {
+            "type": "object",
+            "name": "reloadOptions",
+            "optional": true,
+            "properties": {
+              "ignoreCache": {
+                "type": "boolean",
+                "optional": true,
+                "description": "When true, the loader will bypass the cache for all inspected page resources loaded before the <code>load</code> event is fired. The effect is similar to pressing Ctrl+Shift+R in the inspected window or within the Developer Tools window."
+              },
+              "userAgent": {
+                "type": "string",
+                "optional": true,
+                "description": "If specified, the string will override the value of the <code>User-Agent</code> HTTP header that's sent while loading the resources of the inspected page. The string will also override the value of the <code>navigator.userAgent</code> property that's returned to any scripts that are running within the inspected page."
+              },
+              "injectedScript": {
+                "type": "string",
+                "optional": true,
+                "description": "If specified, the script will be injected into every frame of the inspected page immediately upon load, before any of the frame's scripts. The script will not be injected after subsequent reloads&mdash;for example, if the user presses Ctrl+R."
+              },
+              "preprocessorScript": {
+                "unsupported": true,
+                "type": "string",
+                "deprecated": "Please avoid using this parameter, it will be removed soon.",
+                "optional": true,
+                "description": "If specified, this script evaluates into a function that accepts three string arguments: the source to preprocess, the URL of the source, and a function name if the source is an DOM event handler. The preprocessorerScript function should return a string to be compiled by Chrome in place of the input source. In the case that the source is a DOM event handler, the returned source must compile to a single JS function."
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "getResources",
+        "unsupported": true,
+        "type": "function",
+        "description": "Retrieves the list of resources from the inspected page.",
+        "unsupported": true,
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "callback",
+            "type": "function",
+            "description": "A function that receives the list of resources when the request completes.",
+            "parameters": [
+              {
+                "name": "resources",
+                "type": "array",
+                "items": { "$ref": "Resource" },
+                "description": "The resources within the page."
+              }
+            ]
+          }
+        ]
+      }
+    ],
+    "events": [
+      {
+        "name": "onResourceAdded",
+        "unsupported": true,
+        "type": "function",
+        "description": "Fired when a new resource is added to the inspected page.",
+        "parameters": [
+          {
+            "name": "resource",
+            "$ref": "Resource"
+          }
+        ]
+      },
+      {
+        "name": "onResourceContentCommitted",
+        "unsupported": true,
+        "type": "function",
+        "description": "Fired when a new revision of the resource is committed (e.g. user saves an edited version of the resource in the Developer Tools).",
+        "parameters": [
+          {
+            "name": "resource",
+            "$ref": "Resource"
+          },
+          {
+            "name": "content",
+            "type": "string",
+            "description": "New content of the resource."
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -4,15 +4,17 @@
 
 browser.jar:
     content/browser/schemas/bookmarks.json
     content/browser/schemas/browser_action.json
     content/browser/schemas/browsing_data.json
     content/browser/schemas/commands.json
     content/browser/schemas/context_menus.json
     content/browser/schemas/context_menus_internal.json
+    content/browser/schemas/devtools.json
+    content/browser/schemas/devtools_inspected_window.json
     content/browser/schemas/history.json
     content/browser/schemas/omnibox.json
     content/browser/schemas/page_action.json
     content/browser/schemas/sessions.json
     content/browser/schemas/tabs.json
     content/browser/schemas/theme.json
     content/browser/schemas/windows.json
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -37,16 +37,18 @@ support-files =
 [browser_ext_contextMenus_checkboxes.js]
 [browser_ext_contextMenus_chrome.js]
 [browser_ext_contextMenus_icons.js]
 [browser_ext_contextMenus_onclick.js]
 [browser_ext_contextMenus_radioGroups.js]
 [browser_ext_contextMenus_uninstall.js]
 [browser_ext_contextMenus_urlPatterns.js]
 [browser_ext_currentWindow.js]
+[browser_ext_devtools_inspectedWindow.js]
+[browser_ext_devtools_page.js]
 [browser_ext_getViews.js]
 [browser_ext_incognito_views.js]
 [browser_ext_incognito_popup.js]
 [browser_ext_lastError.js]
 [browser_ext_omnibox.js]
 [browser_ext_optionsPage_privileges.js]
 [browser_ext_pageAction_context.js]
 [browser_ext_pageAction_popup.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow.js
@@ -0,0 +1,110 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetter(this, "gDevTools",
+                                  "resource://devtools/client/framework/gDevTools.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "devtools",
+                                  "resource://devtools/shared/Loader.jsm");
+
+/**
+ * this test file ensures that:
+ *
+ * - the devtools page gets only a subset of the runtime API namespace.
+ * - devtools.inspectedWindow.tabId is the same tabId that we can retrieve
+ *   in the background page using the tabs API namespace.
+ * - devtools API is available in the devtools page sub-frames when a valid
+ *   extension URL has been loaded.
+ */
+add_task(function* test_devtools_inspectedWindow_tabId() {
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+
+  async function background() {
+    browser.test.assertEq(undefined, browser.devtools,
+                          "No devtools APIs should be available in the background page");
+
+    const tabs = await browser.tabs.query({active: true, lastFocusedWindow: true});
+    browser.test.sendMessage("current-tab-id", tabs[0].id);
+  }
+
+  function devtools_page() {
+    browser.test.assertEq(undefined, browser.runtime.getBackgroundPage,
+      "The `runtime.getBackgroundPage` API method should be missing in a devtools_page context"
+    );
+
+    try {
+      let tabId = browser.devtools.inspectedWindow.tabId;
+      browser.test.sendMessage("inspectedWindow-tab-id", tabId);
+    } catch (err) {
+      browser.test.sendMessage("inspectedWindow-tab-id", undefined);
+      throw err;
+    }
+  }
+
+  function devtools_page_iframe() {
+    try {
+      let tabId = browser.devtools.inspectedWindow.tabId;
+      browser.test.sendMessage("devtools_page_iframe.inspectedWindow-tab-id", tabId);
+    } catch (err) {
+      browser.test.fail(`Error: ${err} :: ${err.stack}`);
+      browser.test.sendMessage("devtools_page_iframe.inspectedWindow-tab-id", undefined);
+    }
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      devtools_page: "devtools_page.html",
+    },
+    files: {
+      "devtools_page.html": `<!DOCTYPE html>
+      <html>
+       <head>
+         <meta charset="utf-8">
+       </head>
+       <body>
+         <iframe src="/devtools_page_iframe.html"></iframe>
+         <script src="devtools_page.js"></script>
+       </body>
+      </html>`,
+      "devtools_page.js": devtools_page,
+      "devtools_page_iframe.html": `<!DOCTYPE html>
+      <html>
+       <head>
+         <meta charset="utf-8">
+       </head>
+       <body>
+         <script src="devtools_page_iframe.js"></script>
+       </body>
+      </html>`,
+      "devtools_page_iframe.js": devtools_page_iframe,
+    },
+  });
+
+  yield extension.startup();
+
+  let backgroundPageCurrentTabId = yield extension.awaitMessage("current-tab-id");
+
+  let target = devtools.TargetFactory.forTab(tab);
+
+  yield gDevTools.showToolbox(target, "webconsole");
+  info("developer toolbox opened");
+
+  let devtoolsInspectedWindowTabId = yield extension.awaitMessage("inspectedWindow-tab-id");
+
+  is(devtoolsInspectedWindowTabId, backgroundPageCurrentTabId,
+     "Got the expected tabId from devtool.inspectedWindow.tabId");
+
+  let devtoolsPageIframeTabId = yield extension.awaitMessage("devtools_page_iframe.inspectedWindow-tab-id");
+
+  is(devtoolsPageIframeTabId, backgroundPageCurrentTabId,
+     "Got the expected tabId from devtool.inspectedWindow.tabId called in a devtool_page iframe");
+
+  yield gDevTools.closeToolbox(target);
+
+  yield target.destroy();
+
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab);
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_devtools_page.js
@@ -0,0 +1,84 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetter(this, "gDevTools",
+                                  "resource://devtools/client/framework/gDevTools.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "devtools",
+                                  "resource://devtools/shared/Loader.jsm");
+
+/**
+ * This test file ensures that:
+ *
+ * - the devtools_page property creates a new WebExtensions context
+ * - the devtools_page can exchange messages with the background page
+ */
+
+add_task(function* test_devtools_page_runtime_api_messaging() {
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+
+  function background() {
+    browser.runtime.onConnect.addListener((port) => {
+      let portMessageReceived = false;
+
+      port.onDisconnect.addListener(() => {
+        browser.test.assertTrue(portMessageReceived,
+                                "Got a port message before the port disconnect event");
+        browser.test.notifyPass("devtools_page_connect.done");
+      });
+
+      port.onMessage.addListener((msg) => {
+        portMessageReceived = true;
+        browser.test.assertEq("devtools -> background port message", msg,
+                              "Got the expected message from the devtools page");
+        port.postMessage("background -> devtools port message");
+      });
+    });
+  }
+
+  function devtools_page() {
+    const port = browser.runtime.connect();
+    port.onMessage.addListener((msg) => {
+      browser.test.assertEq("background -> devtools port message", msg,
+                            "Got the expected message from the background page");
+      port.disconnect();
+    });
+    port.postMessage("devtools -> background port message");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      devtools_page: "devtools_page.html",
+    },
+    files: {
+      "devtools_page.html": `<!DOCTYPE html>
+      <html>
+       <head>
+         <meta charset="utf-8">
+       </head>
+       <body>
+         <script src="devtools_page.js"></script>
+       </body>
+      </html>`,
+      "devtools_page.js": devtools_page,
+    },
+  });
+
+  yield extension.startup();
+
+  let target = devtools.TargetFactory.forTab(tab);
+
+  yield gDevTools.showToolbox(target, "webconsole");
+  info("developer toolbox opened");
+
+  yield extension.awaitFinish("devtools_page_connect.done");
+
+  yield gDevTools.closeToolbox(target);
+
+  yield target.destroy();
+
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab);
+});
--- a/browser/components/extensions/test/xpcshell/xpcshell.ini
+++ b/browser/components/extensions/test/xpcshell/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 firefox-appdir = browser
 tags = webextensions
 
 [test_ext_bookmarks.js]
 [test_ext_browsingData_settings.js]
 [test_ext_history.js]
 [test_ext_manifest_commands.js]
 [test_ext_manifest_omnibox.js]
--- a/browser/components/feeds/test/unit/xpcshell.ini
+++ b/browser/components/feeds/test/unit/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head = head_feeds.js
-tail = 
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_355473.js]
 [test_758990.js]
--- a/browser/components/migration/tests/unit/xpcshell.ini
+++ b/browser/components/migration/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_migration.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 support-files =
   Library/**
   AppData/**
 
 [test_automigration.js]
 [test_Chrome_cookies.js]
--- a/browser/components/newtab/tests/xpcshell/xpcshell.ini
+++ b/browser/components/newtab/tests/xpcshell/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_AboutNewTabService.js]
 [test_NewTabPrefsProvider.js]
 [test_NewTabSearchProvider.js]
 [test_NewTabURL.js]
 [test_PlacesProvider.js]
--- a/browser/components/places/tests/unit/xpcshell.ini
+++ b/browser/components/places/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_bookmarks.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 support-files =
   bookmarks.glue.html
   bookmarks.glue.json
   corruptDB.sqlite
   distribution.ini
 
--- a/browser/components/sessionstore/test/unit/xpcshell.ini
+++ b/browser/components/sessionstore/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 support-files =
   data/sessionCheckpoints_all.json
   data/sessionstore_invalid.js
   data/sessionstore_valid.js
 
 [test_backup_once.js]
--- a/browser/components/shell/test/unit/xpcshell.ini
+++ b/browser/components/shell/test/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 head = 
-tail = 
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_421977.js]
--- a/browser/components/syncedtabs/test/xpcshell/xpcshell.ini
+++ b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head = head.js
-tail =
 firefox-appdir = browser
 
 [test_EventEmitter.js]
 [test_SyncedTabsDeckStore.js]
 [test_SyncedTabsListStore.js]
 [test_SyncedTabsDeckComponent.js]
 [test_TabListComponent.js]
--- a/browser/components/translation/test/unit/xpcshell.ini
+++ b/browser/components/translation/test/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 head = 
-tail = 
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_cld2.js]
--- a/browser/experiments/test/xpcshell/xpcshell.ini
+++ b/browser/experiments/test/xpcshell/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 tags = addons
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 support-files =
   experiments_1.manifest
   experiment-1.xpi
   experiment-1a.xpi
   experiment-2.xpi
new file mode 100644
--- /dev/null
+++ b/browser/extensions/disableSHA1rollout/README.md
@@ -0,0 +1,99 @@
+This system add-on is a follow-up to the MITM prevalence experiment. The purpose
+is to facilitate rolling out the disabling of SHA-1 in signatures on
+certificates issued by publicly-trusted roots. When installed, this add-on will
+perform a number of checks to determine if it should change the preference that
+controls the SHA-1 policy. First, this should only apply to users on the beta
+update channel. It should also only apply to users who have not otherwise
+changed the policy to always allow or always forbid SHA-1. Additionally, it
+must double-check that the user is not affected by a TLS intercepting proxy
+using a publicly-trusted root. If these checks pass, the add-on will divide the
+population into a test group and a control group (starting on a 10%/90% split).
+The test group will have the policy changed. After doing this, a telemetry
+payload is reported with the following values:
+
+* cohortName -- the name of the group this user is in:
+  1. "notSafeToDisableSHA1" if the user is behind a MITM proxy using a
+     publicly-trusted root
+  2. "optedOut" if the user already set the SHA-1 policy to always allow or
+     always forbid
+  3. "optedIn" if the user already set the SHA-1 policy to only allow for
+     non-built-in roots
+  4. "test" if the user is in the test cohort (and SHA-1 will be disabled)
+  5. "control" if the user is not in the test cohort
+* errorCode -- 0 for successful connections, some PR error code otherwise
+* error -- a short description of one of four error conditions encountered, if
+  applicable, and an empty string otherwise:
+  1. "timeout" if the connection to telemetry.mozilla.org timed out
+  2. "user override" if the user has stored a permanent certificate exception
+     override for telemetry.mozilla.org (due to technical limitations, we can't
+     gather much information in this situation)
+  3. "certificate reverification" if re-building the certificate chain after
+     connecting failed for some reason (unfortunately this step is necessary
+     due to technical limitations)
+  4. "connection error" if the connection to telemetry.mozilla.org failed for
+     another reason
+* chain -- a list of dictionaries each corresponding to a certificate in the
+  verified certificate chain, if it was successfully constructed. The first
+  entry is the end-entity certificate. The last entry is the root certificate.
+  This will be empty if the connection failed or if reverification failed. Each
+  element in the list contains the following values:
+  * sha256Fingerprint -- a hex string representing the SHA-256 hash of the
+    certificate
+  * isBuiltInRoot -- true if the certificate is a trust anchor in the web PKI,
+    false otherwise
+  * signatureAlgorithm -- a description of the algorithm used to sign the
+    certificate. Will be one of "md2WithRSAEncryption", "md5WithRSAEncryption",
+    "sha1WithRSAEncryption", "sha256WithRSAEncryption",
+    "sha384WithRSAEncryption", "sha512WithRSAEncryption", "ecdsaWithSHA1",
+    "ecdsaWithSHA224", "ecdsaWithSHA256", "ecdsaWithSHA384", "ecdsaWithSHA512",
+    or "unknown".
+* disabledSHA1 -- true if SHA-1 was disabled, false otherwise
+* didNotDisableSHA1Because -- a short string describing why SHA-1 could not be
+    disabled, if applicable. Reasons are limited to:
+    1. "MITM" if the user is behind a TLS intercepting proxy using a
+       publicly-trusted root
+    2. "connection error" if there was an error connecting to
+       telemetry.mozilla.org
+    3. "code error" if some inconsistent state was detected, and it was
+       determined that the experiment should not attempt to change the
+       preference
+    4. "preference:userReset" if the user reset the SHA-1 policy after it had
+       been changed by this add-on
+    5. "preference:allow" if the user had already configured Firefox to always
+       accept SHA-1 signatures
+    6. "preference:forbid" if the user had already configured Firefox to always
+       forbid SHA-1 signatures
+
+For a connection not intercepted by a TLS proxy and where the user is in the
+test cohort, the expected result will be:
+
+    { "cohortName": "test",
+      "errorCode": 0,
+      "error": "",
+      "chain": [
+        { "sha256Fingerprint": "197feaf3faa0f0ad637a89c97cb91336bfc114b6b3018203cbd9c3d10c7fa86c",
+          "isBuiltInRoot": false,
+          "signatureAlgorithm": "sha256WithRSAEncryption"
+        },
+        { "sha256Fingerprint": "154c433c491929c5ef686e838e323664a00e6a0d822ccc958fb4dab03e49a08f",
+          "isBuiltInRoot": false,
+          "signatureAlgorithm": "sha256WithRSAEncryption"
+        },
+        { "sha256Fingerprint": "4348a0e9444c78cb265e058d5e8944b4d84f9662bd26db257f8934a443c70161",
+          "isBuiltInRoot": true,
+          "signatureAlgorithm": "sha1WithRSAEncryption"
+        }
+      ],
+      "disabledSHA1": true,
+      "didNotDisableSHA1Because": ""
+    }
+
+When this result is encountered, the user's preferences are updated to disable
+SHA-1 in signatures on certificates issued by publicly-trusted roots.
+Similarly, if the user is behind a TLS intercepting proxy but the root
+certificate is not part of the public web PKI, we can also disable SHA-1 in
+signatures on certificates issued by publicly-trusted roots.
+
+If the user has already indicated in their preferences that they will always
+accept SHA-1 in signatures or that they will never accept SHA-1 in signatures,
+then the preference is not changed.
copy from browser/extensions/e10srollout/bootstrap.js
copy to browser/extensions/disableSHA1rollout/bootstrap.js
--- a/browser/extensions/e10srollout/bootstrap.js
+++ b/browser/extensions/disableSHA1rollout/bootstrap.js
@@ -4,171 +4,303 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/UpdateUtils.jsm");
+Cu.import("resource://gre/modules/TelemetryController.jsm");
 
- // The amount of people to be part of e10s
+ // Percentage of the population to attempt to disable SHA-1 for, by channel.
 const TEST_THRESHOLD = {
-  "beta"    : 0.5,  // 50%
-  "release" : 1.0,  // 100%
-};
-
-const ADDON_ROLLOUT_POLICY = {
-  "beta"    : "51alladdons", // Any WebExtension or addon except with mpc = false
-  "release" : "51set1",
+  beta: 0.1, // 10%
 };
 
-const PREF_COHORT_SAMPLE       = "e10s.rollout.cohortSample";
-const PREF_COHORT_NAME         = "e10s.rollout.cohort";
-const PREF_E10S_OPTED_IN       = "browser.tabs.remote.autostart";
-const PREF_E10S_FORCE_ENABLED  = "browser.tabs.remote.force-enable";
-const PREF_E10S_FORCE_DISABLED = "browser.tabs.remote.force-disable";
-const PREF_TOGGLE_E10S         = "browser.tabs.remote.autostart.2";
-const PREF_E10S_ADDON_POLICY   = "extensions.e10s.rollout.policy";
-const PREF_E10S_ADDON_BLOCKLIST = "extensions.e10s.rollout.blocklist";
-const PREF_E10S_HAS_NONEXEMPT_ADDON = "extensions.e10s.rollout.hasAddon";
+const PREF_COHORT_SAMPLE = "disableSHA1.rollout.cohortSample";
+const PREF_COHORT_NAME = "disableSHA1.rollout.cohort";
+const PREF_SHA1_POLICY = "security.pki.sha1_enforcement_level";
+const PREF_SHA1_POLICY_SET_BY_ADDON = "disableSHA1.rollout.policySetByAddOn";
+const PREF_SHA1_POLICY_RESET_BY_USER = "disableSHA1.rollout.userResetPref";
+
+const SHA1_MODE_ALLOW = 0;
+const SHA1_MODE_FORBID = 1;
+const SHA1_MODE_IMPORTED_ROOTS_ONLY = 3;
+const SHA1_MODE_CURRENT_DEFAULT = 4;
 
 function startup() {
-  // In theory we only need to run this once (on install()), but
-  // it's better to also run it on every startup. If the user has
-  // made manual changes to the prefs, this will keep the data
-  // reported more accurate.
-  // It's also fine (and preferred) to just do it here on startup
-  // (instead of observing prefs), because e10s takes a restart
-  // to take effect, so we keep the data based on how it was when
-  // the session started.
-  defineCohort();
+  Preferences.observe(PREF_SHA1_POLICY, policyPreferenceChanged);
 }
 
 function install() {
-  defineCohort();
+  let updateChannel = UpdateUtils.getUpdateChannel(false);
+  if (updateChannel in TEST_THRESHOLD) {
+    makeRequest().then(defineCohort).catch((e) => console.error(e));
+  }
 }
 
-let cohortDefinedOnThisSession = false;
-
-function defineCohort() {
-  // Avoid running twice when it was called by install() first
-  if (cohortDefinedOnThisSession) {
-    return;
-  }
-  cohortDefinedOnThisSession = true;
-
-  let updateChannel = UpdateUtils.getUpdateChannel(false);
-  if (!(updateChannel in TEST_THRESHOLD)) {
-    setCohort("unsupportedChannel");
-    return;
-  }
-
-  let addonPolicy = "unknown";
-  if (updateChannel in ADDON_ROLLOUT_POLICY) {
-    addonPolicy = ADDON_ROLLOUT_POLICY[updateChannel];
-    Preferences.set(PREF_E10S_ADDON_POLICY, addonPolicy);
-    // This is also the proper place to set the blocklist pref
-    // in case it is necessary.
-
-    // Tab Mix Plus exception tracked at bug 1185672.
-    Preferences.set(PREF_E10S_ADDON_BLOCKLIST,
-                    "{dc572301-7619-498c-a57d-39143191b318}");
-  } else {
-    Preferences.reset(PREF_E10S_ADDON_POLICY);
-  }
-
-  let userOptedOut = optedOut();
-  let userOptedIn = optedIn();
-  let disqualified = (Services.appinfo.multiprocessBlockPolicy != 0);
-  let testGroup = (getUserSample() < TEST_THRESHOLD[updateChannel]);
-  let hasNonExemptAddon = Preferences.get(PREF_E10S_HAS_NONEXEMPT_ADDON, false);
-  let temporaryDisqualification = getTemporaryDisqualification();
-
-  let cohortPrefix = "";
-  if (disqualified) {
-    cohortPrefix = "disqualified-";
-  } else if (hasNonExemptAddon) {
-    cohortPrefix = `addons-set${addonPolicy}-`;
-  }
-
-  if (userOptedOut) {
-    setCohort("optedOut");
-  } else if (userOptedIn) {
-    setCohort("optedIn");
-  } else if (temporaryDisqualification != "") {
-    // Users who are disqualified by the backend (from multiprocessBlockPolicy)
-    // can be put into either the test or control groups, because e10s will
-    // still be denied by the backend, which is useful so that the E10S_STATUS
-    // telemetry probe can be correctly set.
-
-    // For these volatile disqualification reasons, however, we must not try
-    // to activate e10s because the backend doesn't know about it. E10S_STATUS
-    // here will be accumulated as "2 - Disabled", which is fine too.
-    setCohort(`temp-disqualified-${temporaryDisqualification}`);
-    Preferences.reset(PREF_TOGGLE_E10S);
-  } else if (testGroup) {
-    setCohort(`${cohortPrefix}test`);
-    Preferences.set(PREF_TOGGLE_E10S, true);
-  } else {
-    setCohort(`${cohortPrefix}control`);
-    Preferences.reset(PREF_TOGGLE_E10S);
+function policyPreferenceChanged() {
+  let currentPrefValue = Preferences.get(PREF_SHA1_POLICY,
+                                         SHA1_MODE_CURRENT_DEFAULT);
+  Preferences.reset(PREF_SHA1_POLICY_RESET_BY_USER);
+  if (currentPrefValue == SHA1_MODE_CURRENT_DEFAULT) {
+    Preferences.set(PREF_SHA1_POLICY_RESET_BY_USER, true);
   }
 }
 
+function defineCohort(result) {
+  let userOptedOut = optedOut();
+  let userOptedIn = optedIn();
+  let shouldNotDisableSHA1Because = reasonToNotDisableSHA1(result);
+  let safeToDisableSHA1 = shouldNotDisableSHA1Because.length == 0;
+  let updateChannel = UpdateUtils.getUpdateChannel(false);
+  let testGroup = getUserSample() < TEST_THRESHOLD[updateChannel];
+
+  let cohortName;
+  if (!safeToDisableSHA1) {
+    cohortName = "notSafeToDisableSHA1";
+  } else if (userOptedOut) {
+    cohortName = "optedOut";
+  } else if (userOptedIn) {
+    cohortName = "optedIn";
+  } else if (testGroup) {
+    cohortName = "test";
+    Preferences.ignore(PREF_SHA1_POLICY, policyPreferenceChanged);
+    Preferences.set(PREF_SHA1_POLICY, SHA1_MODE_IMPORTED_ROOTS_ONLY);
+    Preferences.observe(PREF_SHA1_POLICY, policyPreferenceChanged);
+    Preferences.set(PREF_SHA1_POLICY_SET_BY_ADDON, true);
+  } else {
+    cohortName = "control";
+  }
+  Preferences.set(PREF_COHORT_NAME, cohortName);
+  reportTelemetry(result, cohortName, shouldNotDisableSHA1Because,
+                  cohortName == "test");
+}
+
 function shutdown(data, reason) {
+  Preferences.ignore(PREF_SHA1_POLICY, policyPreferenceChanged);
 }
 
 function uninstall() {
 }
 
 function getUserSample() {
   let prefValue = Preferences.get(PREF_COHORT_SAMPLE, undefined);
   let value = 0.0;
 
   if (typeof(prefValue) == "string") {
     value = parseFloat(prefValue, 10);
     return value;
   }
 
-  if (typeof(prefValue) == "number") {
-    // convert old integer value
-    value = prefValue / 100;
-  } else {
-    value = Math.random();
-  }
+  value = Math.random();
 
   Preferences.set(PREF_COHORT_SAMPLE, value.toString().substr(0, 8));
   return value;
 }
 
-function setCohort(cohortName) {
-  Preferences.set(PREF_COHORT_NAME, cohortName);
-  try {
-    if (Ci.nsICrashReporter) {
-      Services.appinfo.QueryInterface(Ci.nsICrashReporter).annotateCrashReport("E10SCohort", cohortName);
+function reportTelemetry(result, cohortName, didNotDisableSHA1Because,
+                         disabledSHA1) {
+  result.cohortName = cohortName;
+  result.disabledSHA1 = disabledSHA1;
+  if (cohortName == "optedOut") {
+    let userResetPref = Preferences.get(PREF_SHA1_POLICY_RESET_BY_USER, false);
+    let currentPrefValue = Preferences.get(PREF_SHA1_POLICY,
+                                           SHA1_MODE_CURRENT_DEFAULT);
+    if (userResetPref) {
+      didNotDisableSHA1Because = "preference:userReset";
+    } else if (currentPrefValue == SHA1_MODE_ALLOW) {
+      didNotDisableSHA1Because = "preference:allow";
+    } else {
+      didNotDisableSHA1Because = "preference:forbid";
     }
-  } catch (e) {}
+  }
+  result.didNotDisableSHA1Because = didNotDisableSHA1Because;
+  return TelemetryController.submitExternalPing("disableSHA1rollout", result,
+                                                {});
 }
 
 function optedIn() {
-  return Preferences.get(PREF_E10S_OPTED_IN, false) ||
-         Preferences.get(PREF_E10S_FORCE_ENABLED, false);
+  let policySetByAddOn = Preferences.get(PREF_SHA1_POLICY_SET_BY_ADDON, false);
+  let currentPrefValue = Preferences.get(PREF_SHA1_POLICY,
+                                         SHA1_MODE_CURRENT_DEFAULT);
+  return currentPrefValue == SHA1_MODE_IMPORTED_ROOTS_ONLY && !policySetByAddOn;
 }
 
 function optedOut() {
-  // Users can also opt-out by toggling back the pref to false.
-  // If they reset the pref instead they might be re-enabled if
-  // they are still part of the threshold.
-  return Preferences.get(PREF_E10S_FORCE_DISABLED, false) ||
-         (Preferences.isSet(PREF_TOGGLE_E10S) &&
-          Preferences.get(PREF_TOGGLE_E10S) == false);
+  // Users can also opt-out by setting the policy to always allow or always
+  // forbid SHA-1, or by resetting the preference after this add-on has changed
+  // it (in that case, this will be reported the next time this add-on is
+  // updated).
+  let currentPrefValue = Preferences.get(PREF_SHA1_POLICY,
+                                         SHA1_MODE_CURRENT_DEFAULT);
+  let userResetPref = Preferences.get(PREF_SHA1_POLICY_RESET_BY_USER, false);
+  return currentPrefValue == SHA1_MODE_ALLOW ||
+         currentPrefValue == SHA1_MODE_FORBID ||
+         userResetPref;
+}
+
+function delocalizeAlgorithm(localizedString) {
+  let bundle = Services.strings.createBundle(
+    "chrome://pipnss/locale/pipnss.properties");
+  let algorithmStringIdsToOIDDescriptionMap = {
+    "CertDumpMD2WithRSA":                       "md2WithRSAEncryption",
+    "CertDumpMD5WithRSA":                       "md5WithRSAEncryption",
+    "CertDumpSHA1WithRSA":                      "sha1WithRSAEncryption",
+    "CertDumpSHA256WithRSA":                    "sha256WithRSAEncryption",
+    "CertDumpSHA384WithRSA":                    "sha384WithRSAEncryption",
+    "CertDumpSHA512WithRSA":                    "sha512WithRSAEncryption",
+    "CertDumpAnsiX962ECDsaSignatureWithSha1":   "ecdsaWithSHA1",
+    "CertDumpAnsiX962ECDsaSignatureWithSha224": "ecdsaWithSHA224",
+    "CertDumpAnsiX962ECDsaSignatureWithSha256": "ecdsaWithSHA256",
+    "CertDumpAnsiX962ECDsaSignatureWithSha384": "ecdsaWithSHA384",
+    "CertDumpAnsiX962ECDsaSignatureWithSha512": "ecdsaWithSHA512",
+  };
+
+  let description;
+  Object.keys(algorithmStringIdsToOIDDescriptionMap).forEach((l10nID) => {
+    let candidateLocalizedString = bundle.GetStringFromName(l10nID);
+    if (localizedString == candidateLocalizedString) {
+      description = algorithmStringIdsToOIDDescriptionMap[l10nID];
+    }
+  });
+  if (!description) {
+    return "unknown";
+  }
+  return description;
+}
+
+function getSignatureAlgorithm(cert) {
+  // Certificate  ::=  SEQUENCE  {
+  //      tbsCertificate       TBSCertificate,
+  //      signatureAlgorithm   AlgorithmIdentifier,
+  //      signatureValue       BIT STRING  }
+  let certificate = cert.ASN1Structure.QueryInterface(Ci.nsIASN1Sequence);
+  let signatureAlgorithm = certificate.ASN1Objects
+                                      .queryElementAt(1, Ci.nsIASN1Sequence);
+  // AlgorithmIdentifier  ::=  SEQUENCE  {
+  //      algorithm               OBJECT IDENTIFIER,
+  //      parameters              ANY DEFINED BY algorithm OPTIONAL  }
+
+  // If parameters is NULL (or empty), signatureAlgorithm won't be a container
+  // under this implementation. Just get its displayValue.
+  if (!signatureAlgorithm.isValidContainer) {
+    return signatureAlgorithm.displayValue;
+  }
+  let oid = signatureAlgorithm.ASN1Objects.queryElementAt(0, Ci.nsIASN1Object);
+  return oid.displayValue;
+}
+
+function processCertChain(chain) {
+  let output = [];
+  let enumerator = chain.getEnumerator();
+  while (enumerator.hasMoreElements()) {
+    let cert = enumerator.getNext().QueryInterface(Ci.nsIX509Cert);
+    output.push({
+      sha256Fingerprint: cert.sha256Fingerprint.replace(/:/g, "").toLowerCase(),
+      isBuiltInRoot: cert.isBuiltInRoot,
+      signatureAlgorithm: delocalizeAlgorithm(getSignatureAlgorithm(cert)),
+    });
+  }
+  return output;
 }
 
-/* If this function returns a non-empty string, it
- * means that this particular user should be temporarily
- * disqualified due to some particular reason.
- * If a user shouldn't be disqualified, then an empty
- * string must be returned.
- */
-function getTemporaryDisqualification() {
+class CertificateVerificationResult {
+  constructor(resolve) {
+    this.resolve = resolve;
+  }
+
+  verifyCertFinished(aPRErrorCode, aVerifiedChain, aEVStatus) {
+    let result = { errorCode: aPRErrorCode, error: "", chain: [] };
+    if (aPRErrorCode == 0) {
+      result.chain = processCertChain(aVerifiedChain);
+    } else {
+      result.error = "certificate reverification";
+    }
+    this.resolve(result);
+  }
+}
+
+function makeRequest() {
+  return new Promise((resolve) => {
+    let hostname = "telemetry.mozilla.org";
+    let req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+                .createInstance(Ci.nsIXMLHttpRequest);
+    req.open("GET", "https://" + hostname);
+    req.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+    req.timeout = 30000;
+    req.addEventListener("error", (evt) => {
+      // If we can't connect to telemetry.mozilla.org, then how did we even
+      // download the experiment? In any case, we may still be able to get some
+      // information.
+      let result = { error: "connection error" };
+      if (evt.target.channel && evt.target.channel.securityInfo) {
+        let securityInfo = evt.target.channel.securityInfo
+                             .QueryInterface(Ci.nsITransportSecurityInfo);
+        if (securityInfo) {
+          result.errorCode = securityInfo.errorCode;
+        }
+        if (securityInfo && securityInfo.failedCertChain) {
+          result.chain = processCertChain(securityInfo.failedCertChain);
+        }
+      }
+      resolve(result);
+    });
+    req.addEventListener("timeout", (evt) => {
+      resolve({ error: "timeout" });
+    });
+    req.addEventListener("load", (evt) => {
+      let securityInfo = evt.target.channel.securityInfo
+                           .QueryInterface(Ci.nsITransportSecurityInfo);
+      if (securityInfo.securityState &
+          Ci.nsIWebProgressListener.STATE_CERT_USER_OVERRIDDEN) {
+        resolve({ error: "user override" });
+        return;
+      }
+      let sslStatus = securityInfo.QueryInterface(Ci.nsISSLStatusProvider)
+                        .SSLStatus;
+      let certdb = Cc["@mozilla.org/security/x509certdb;1"]
+                     .getService(Ci.nsIX509CertDB);
+      let result = new CertificateVerificationResult(resolve);
+      // Unfortunately, we don't have direct access to the verified certificate
+      // chain as built by the AuthCertificate hook, so we have to re-build it
+      // here. In theory we are likely to get the same result.
+      certdb.asyncVerifyCertAtTime(sslStatus.serverCert,
+                                   2, // certificateUsageSSLServer
+                                   0, // flags
+                                   hostname,
+                                   Date.now() / 1000,
+                                   result);
+    });
+    req.send();
+  });
+}
+
+// As best we know, it is safe to disable SHA1 if the connection was successful
+// and either the connection was MITM'd by a root not in the public PKI or the
+// chain is part of the public PKI and is the one served by the real
+// telemetry.mozilla.org.
+// This will return a short string description of why it might not be safe to
+// disable SHA1 or an empty string if it is safe to disable SHA1.
+function reasonToNotDisableSHA1(result) {
+  if (!("errorCode" in result) || result.errorCode != 0) {
+    return "connection error";
+  }
+  if (!("chain" in result)) {
+    return "code error";
+  }
+  if (!result.chain[result.chain.length - 1].isBuiltInRoot) {
+    return "";
+  }
+  if (result.chain.length != 3) {
+    return "MITM";
+  }
+  const kEndEntityFingerprint = "197feaf3faa0f0ad637a89c97cb91336bfc114b6b3018203cbd9c3d10c7fa86c";
+  const kIntermediateFingerprint = "154c433c491929c5ef686e838e323664a00e6a0d822ccc958fb4dab03e49a08f";
+  const kRootFingerprint = "4348a0e9444c78cb265e058d5e8944b4d84f9662bd26db257f8934a443c70161";
+  if (result.chain[0].sha256Fingerprint != kEndEntityFingerprint ||
+      result.chain[1].sha256Fingerprint != kIntermediateFingerprint ||
+      result.chain[2].sha256Fingerprint != kRootFingerprint) {
+    return "MITM";
+  }
   return "";
 }
copy from browser/extensions/e10srollout/install.rdf.in
copy to browser/extensions/disableSHA1rollout/install.rdf.in
--- a/browser/extensions/e10srollout/install.rdf.in
+++ b/browser/extensions/disableSHA1rollout/install.rdf.in
@@ -4,29 +4,29 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
 #filter substitution
 
 <RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
      xmlns:em="http://www.mozilla.org/2004/em-rdf#">
 
   <Description about="urn:mozilla:install-manifest">
-    <em:id>e10srollout@mozilla.org</em:id>
-    <em:version>1.7</em:version>
+    <em:id>disableSHA1rollout@mozilla.org</em:id>
+    <em:version>1.0</em:version>
     <em:type>2</em:type>
     <em:bootstrap>true</em:bootstrap>
     <em:multiprocessCompatible>true</em:multiprocessCompatible>
 
     <!-- Target Application this theme can install into,
         with minimum and maximum supported versions. -->
     <em:targetApplication>
       <Description>
         <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
         <em:minVersion>@MOZ_APP_VERSION@</em:minVersion>
         <em:maxVersion>@MOZ_APP_MAXVERSION@</em:maxVersion>
       </Description>
     </em:targetApplication>
 
     <!-- Front End MetaData -->
-    <em:name>Multi-process staged rollout</em:name>
-    <em:description>Staged rollout of Firefox multi-process feature.</em:description>
+    <em:name>SHA-1 deprecation staged rollout</em:name>
+    <em:description>Staged rollout deprecating SHA-1 in certificate signatures.</em:description>
   </Description>
 </RDF>
copy from browser/extensions/e10srollout/moz.build
copy to browser/extensions/disableSHA1rollout/moz.build
--- a/browser/extensions/e10srollout/moz.build
+++ b/browser/extensions/disableSHA1rollout/moz.build
@@ -2,15 +2,15 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DEFINES['MOZ_APP_VERSION'] = CONFIG['MOZ_APP_VERSION']
 DEFINES['MOZ_APP_MAXVERSION'] = CONFIG['MOZ_APP_MAXVERSION']
 
-FINAL_TARGET_FILES.features['e10srollout@mozilla.org'] += [
+FINAL_TARGET_FILES.features['disableSHA1rollout@mozilla.org'] += [
   'bootstrap.js'
 ]
 
-FINAL_TARGET_PP_FILES.features['e10srollout@mozilla.org'] += [
+FINAL_TARGET_PP_FILES.features['disableSHA1rollout@mozilla.org'] += [
   'install.rdf.in'
 ]
--- a/browser/extensions/formautofill/test/unit/xpcshell.ini
+++ b/browser/extensions/formautofill/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 firefox-appdir = browser
 head = head.js
-tail =
 support-files =
 
 [test_autofillFormFields.js]
 [test_collectFormFields.js]
 [test_markAsAutofillField.js]
 [test_populateFieldValues.js]
 [test_profileAutocompleteResult.js]
 [test_profileStorage.js]
--- a/browser/extensions/moz.build
+++ b/browser/extensions/moz.build
@@ -1,16 +1,17 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += [
     'aushelper',
+    'disableSHA1rollout',
     'e10srollout',
     'pdfjs',
     'pocket',
     'webcompat',
     'shield-recipe-client',
 ]
 
 # Only include the following system add-ons if building Aurora or Nightly
--- a/browser/locales/en-US/chrome/browser/aboutTabCrashed.dtd
+++ b/browser/locales/en-US/chrome/browser/aboutTabCrashed.dtd
@@ -22,9 +22,9 @@
 <!ENTITY tabCrashed.requestReport "Report this tab">
 <!ENTITY tabCrashed.sendReport2 "Send a crash report for the tab you are viewing">
 <!ENTITY tabCrashed.commentPlaceholder2 "Optional comments (comments are publicly visible)">
 <!ENTITY tabCrashed.includeURL2 "Include page URL with this crash report">
 <!ENTITY tabCrashed.emailPlaceholder "Enter your email address here">
 <!ENTITY tabCrashed.emailMe "Email me when more information is available">
 <!ENTITY tabCrashed.reportSent "Crash report already submitted; thank you for helping make &brandShortName; better!">
 <!ENTITY tabCrashed.requestAutoSubmit2 "Report background tabs">
-<!ENTITY tabCrashed.autoSubmit "Update preferences to automatically submit backlogged crash reports (and get fewer messages like this from us in the future)">
\ No newline at end of file
+<!ENTITY tabCrashed.autoSubmit2 "Update preferences to automatically send crash reports, including reports for crashed background tabs from this session and future sessions">
--- a/browser/locales/en-US/chrome/browser/lightweightThemes.properties
+++ b/browser/locales/en-US/chrome/browser/lightweightThemes.properties
@@ -3,16 +3,10 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 lightweightThemes.recommended-1.name=A Web Browser Renaissance
 lightweightThemes.recommended-1.description=A Web Browser Renaissance is (C) Sean.Martell. Available under CC-BY-SA. No warranty.
 
 lightweightThemes.recommended-2.name=Space Fantasy
 lightweightThemes.recommended-2.description=Space Fantasy is (C) fx5800p. Available under CC-BY-SA. No warranty.
 
-lightweightThemes.recommended-3.name=Linen Light
-lightweightThemes.recommended-3.description=Linen Light is (C) DVemer. Available under CC-BY-SA. No warranty.
-
 lightweightThemes.recommended-4.name=Pastel Gradient
 lightweightThemes.recommended-4.description=Pastel Gradient is (C) darrinhenein. Available under CC-BY. No warranty.
-
-lightweightThemes.recommended-5.name=Carbon Light
-lightweightThemes.recommended-5.description=Carbon Light is (C) Jaxivo. Available under CC-BY-SA. No warranty.
--- a/browser/modules/ContentWebRTC.jsm
+++ b/browser/modules/ContentWebRTC.jsm
@@ -20,26 +20,28 @@ this.ContentWebRTC = {
   _initialized: false,
 
   init() {
     if (this._initialized)
       return;
 
     this._initialized = true;
     Services.obs.addObserver(handleGUMRequest, "getUserMedia:request", false);
+    Services.obs.addObserver(handleGUMStop, "recording-device-stopped", false);
     Services.obs.addObserver(handlePCRequest, "PeerConnection:request", false);
     Services.obs.addObserver(updateIndicators, "recording-device-events", false);
     Services.obs.addObserver(removeBrowserSpecificIndicator, "recording-window-ended", false);
 
     if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT)
       Services.obs.addObserver(processShutdown, "content-child-shutdown", false);
   },
 
   uninit() {
     Services.obs.removeObserver(handleGUMRequest, "getUserMedia:request");
+    Services.obs.removeObserver(handleGUMStop, "recording-device-stopped");
     Services.obs.removeObserver(handlePCRequest, "PeerConnection:request");
     Services.obs.removeObserver(updateIndicators, "recording-device-events");
     Services.obs.removeObserver(removeBrowserSpecificIndicator, "recording-window-ended");
 
     if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT)
       Services.obs.removeObserver(processShutdown, "content-child-shutdown");
 
     this._initialized = false;
@@ -119,16 +121,29 @@ function handlePCRequest(aSubject, aTopi
     innerWindowID,
     callID,
     documentURI: contentWindow.document.documentURI,
     secure: isSecure,
   };
   mm.sendAsyncMessage("rtcpeer:Request", request);
 }
 
+function handleGUMStop(aSubject, aTopic, aData) {
+  let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
+
+  let request = {
+    windowID: aSubject.windowID,
+    rawID: aSubject.rawID,
+    mediaSource: aSubject.mediaSource,
+  };
+
+  let mm = getMessageManagerForWindow(contentWindow);
+  mm.sendAsyncMessage("webrtc:StopRecording", request);
+}
+
 function handleGUMRequest(aSubject, aTopic, aData) {
   let constraints = aSubject.getConstraints();
   let secure = aSubject.isSecure;
   let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
 
   contentWindow.navigator.mozGetUserMediaDevices(
     constraints,
     function(devices) {
--- a/browser/modules/test/unit/social/xpcshell.ini
+++ b/browser/modules/test/unit/social/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 support-files = blocklist.xml
 
 [test_social.js]
 [test_socialDisabledStartup.js]
 [test_SocialService.js]
 [test_SocialServiceMigration21.js]
--- a/browser/modules/test/xpcshell/xpcshell.ini
+++ b/browser/modules/test/xpcshell/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_AttributionCode.js]
 skip-if = os != 'win'
 [test_DirectoryLinksProvider.js]
 [test_SitePermissions.js]
 [test_LaterRun.js]
--- a/browser/modules/webrtcUI.jsm
+++ b/browser/modules/webrtcUI.jsm
@@ -40,16 +40,17 @@ this.webrtcUI = {
     ppmm.addMessageListener("webrtc:UpdateGlobalIndicators", this);
     ppmm.addMessageListener("child-process-shutdown", this);
 
     let mm = Cc["@mozilla.org/globalmessagemanager;1"]
                .getService(Ci.nsIMessageListenerManager);
     mm.addMessageListener("rtcpeer:Request", this);
     mm.addMessageListener("rtcpeer:CancelRequest", this);
     mm.addMessageListener("webrtc:Request", this);
+    mm.addMessageListener("webrtc:StopRecording", this);
     mm.addMessageListener("webrtc:CancelRequest", this);
     mm.addMessageListener("webrtc:UpdateBrowserIndicators", this);
   },
 
   uninit() {
     Services.obs.removeObserver(maybeAddMenuIndicator, "browser-delayed-startup-finished");
 
     let ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"]
@@ -57,26 +58,28 @@ this.webrtcUI = {
     ppmm.removeMessageListener("webrtc:UpdatingIndicators", this);
     ppmm.removeMessageListener("webrtc:UpdateGlobalIndicators", this);
 
     let mm = Cc["@mozilla.org/globalmessagemanager;1"]
                .getService(Ci.nsIMessageListenerManager);
     mm.removeMessageListener("rtcpeer:Request", this);
     mm.removeMessageListener("rtcpeer:CancelRequest", this);
     mm.removeMessageListener("webrtc:Request", this);
+    mm.removeMessageListener("webrtc:StopRecording");
     mm.removeMessageListener("webrtc:CancelRequest", this);
     mm.removeMessageListener("webrtc:UpdateBrowserIndicators", this);
 
     if (gIndicatorWindow) {
       gIndicatorWindow.close();
       gIndicatorWindow = null;
     }
   },
 
   processIndicators: new Map(),
+  activePerms: new Map(),
 
   get showGlobalIndicator() {
     for (let [, indicators] of this.processIndicators) {
       if (indicators.showGlobalIndicator)
         return true;
     }
     return false;
   },
@@ -137,18 +140,23 @@ this.webrtcUI = {
 
   swapBrowserForNotification(aOldBrowser, aNewBrowser) {
     for (let stream of this._streams) {
       if (stream.browser == aOldBrowser)
         stream.browser = aNewBrowser;
     }
   },
 
+  forgetActivePermissionsFromBrowser(aBrowser) {
+    webrtcUI.activePerms.delete(aBrowser.outerWindowID);
+  },
+
   forgetStreamsFromBrowser(aBrowser) {
     this._streams = this._streams.filter(stream => stream.browser != aBrowser);
+    webrtcUI.forgetActivePermissionsFromBrowser(aBrowser);
   },
 
   showSharingDoorhanger(aActiveStream) {
     let browserWindow = aActiveStream.browser.ownerGlobal;
     if (aActiveStream.tab) {
       browserWindow.gBrowser.selectedTab = aActiveStream.tab;
     } else {
       aActiveStream.browser.focus();
@@ -260,16 +268,19 @@ this.webrtcUI = {
           callID: aMessage.data
         });
         this.emitter.emit("peer-request-cancel", params);
         break;
       }
       case "webrtc:Request":
         prompt(aMessage.target, aMessage.data);
         break;
+      case "webrtc:StopRecording":
+        stopRecording(aMessage.target, aMessage.data);
+        break;
       case "webrtc:CancelRequest":
         removePrompt(aMessage.target, aMessage.data);
         break;
       case "webrtc:UpdatingIndicators":
         webrtcUI._streams = [];
         break;
       case "webrtc:UpdateGlobalIndicators":
         updateIndicators(aMessage.data, aMessage.target);
@@ -329,16 +340,31 @@ function getHost(uri, href) {
       const kBundleURI = "chrome://browser/locale/browser.properties";
       let bundle = Services.strings.createBundle(kBundleURI);
       host = bundle.GetStringFromName("getUserMedia.sharingMenuUnknownHost");
     }
   }
   return host;
 }
 
+function stopRecording(aBrowser, aRequest) {
+  let outerWindowID = aBrowser.outerWindowID;
+
+  if (!webrtcUI.activePerms.has(outerWindowID)) {
+    return;
+  }
+
+  if (!aRequest.rawID) {
+    webrtcUI.activePerms.delete(outerWindowID);
+  } else {
+    let set = webrtcUI.activePerms.get(outerWindowID);
+    set.delete(aRequest.windowID + aRequest.mediaSource + aRequest.rawID);
+  }
+}
+
 function prompt(aBrowser, aRequest) {
   let { audioDevices, videoDevices, sharingScreen, sharingAudio,
         requestTypes } = aRequest;
 
   // If the user has already denied access once in this tab,
   // deny again without even showing the notification icon.
   if ((audioDevices.length && SitePermissions
         .get(null, "microphone", aBrowser).state == SitePermissions.BLOCK) ||
@@ -471,28 +497,47 @@ function prompt(aBrowser, aRequest) {
         if (mediaManagerPerm) {
           perms.remove(uri, "MediaManagerVideo");
         }
 
         // Screen sharing shouldn't follow the camera permissions.
         if (videoDevices.length && sharingScreen)
           camAllowed = false;
 
-        if ((!audioDevices.length || micAllowed) &&
-            (!videoDevices.length || camAllowed)) {
-          // All permissions we were about to request are already persistently set.
+        let activeCamera;
+        let activeMic;
+
+        for (let device of videoDevices) {
+          let set = webrtcUI.activePerms.get(aBrowser.outerWindowID);
+          if (set && set.has(aRequest.windowID + device.mediaSource + device.id)) {
+            activeCamera = device;
+            break;
+          }
+        }
+
+        for (let device of audioDevices) {
+          let set = webrtcUI.activePerms.get(aBrowser.outerWindowID);
+          if (set && set.has(aRequest.windowID + device.mediaSource + device.id)) {
+            activeMic = device;
+            break;
+          }
+        }
+
+        if ((!audioDevices.length || micAllowed || activeMic) &&
+            (!videoDevices.length || camAllowed || activeCamera)) {
           let allowedDevices = [];
-          if (videoDevices.length && camAllowed) {
-            allowedDevices.push(videoDevices[0].deviceIndex);
+          if (videoDevices.length) {
+            allowedDevices.push((activeCamera || videoDevices[0]).deviceIndex);
             Services.perms.add(uri, "MediaManagerVideo",
                                Services.perms.ALLOW_ACTION,
                                Services.perms.EXPIRE_SESSION);
           }
-          if (audioDevices.length && micAllowed)
-            allowedDevices.push(audioDevices[0].deviceIndex);
+          if (audioDevices.length) {
+            allowedDevices.push((activeMic || audioDevices[0]).deviceIndex);
+          }
 
           // Remember on which URIs we found persistent permissions so that we
           // can remove them if the user clicks 'Stop Sharing'. There's no
           // other way for the stop sharing code to know the hostnames of frames
           // using devices until bug 1066082 is fixed.
           let browser = this.browser;
           browser._devicePermissionURIs = browser._devicePermissionURIs || [];
           browser._devicePermissionURIs.push(uri);
@@ -675,29 +720,51 @@ function prompt(aBrowser, aRequest) {
           let videoDeviceIndex = doc.getElementById(listId).value;
           let allowCamera = videoDeviceIndex != "-1";
           if (allowCamera) {
             allowedDevices.push(videoDeviceIndex);
             // Session permission will be removed after use
             // (it's really one-shot, not for the entire session)
             perms.add(uri, "MediaManagerVideo", perms.ALLOW_ACTION,
                       perms.EXPIRE_SESSION);
+            if (!webrtcUI.activePerms.has(aBrowser.outerWindowID)) {
+              webrtcUI.activePerms.set(aBrowser.outerWindowID, new Set());
+            }
+
+            for (let device of videoDevices) {
+              if (device.deviceIndex == videoDeviceIndex) {
+                webrtcUI.activePerms.get(aBrowser.outerWindowID)
+                        .add(aRequest.windowID + device.mediaSource + device.id);
+                break;
+              }
+            }
             if (remember)
               SitePermissions.set(uri, "camera", SitePermissions.ALLOW);
           } else {
             let scope = remember ? SitePermissions.SCOPE_PERSISTENT : SitePermissions.SCOPE_TEMPORARY;
             SitePermissions.set(uri, "camera", SitePermissions.BLOCK, scope, aBrowser);
           }
         }
         if (audioDevices.length) {
           if (!sharingAudio) {
             let audioDeviceIndex = doc.getElementById("webRTC-selectMicrophone-menulist").value;
             let allowMic = audioDeviceIndex != "-1";
             if (allowMic) {
               allowedDevices.push(audioDeviceIndex);
+              if (!webrtcUI.activePerms.has(aBrowser.outerWindowID)) {
+                webrtcUI.activePerms.set(aBrowser.outerWindowID, new Set());
+              }
+
+              for (let device of audioDevices) {
+                if (device.deviceIndex == audioDeviceIndex) {
+                  webrtcUI.activePerms.get(aBrowser.outerWindowID)
+                          .add(aRequest.windowID + device.mediaSource + device.id);
+                  break;
+                }
+              }
               if (remember)
                 SitePermissions.set(uri, "microphone", SitePermissions.ALLOW);
             } else {
                 let scope = remember ? SitePermissions.SCOPE_PERSISTENT : SitePermissions.SCOPE_TEMPORARY;
                 SitePermissions.set(uri, "microphone", SitePermissions.BLOCK, scope, aBrowser);
             }
           } else {
             // Only one device possible for audio capture.
--- a/browser/themes/shared/controlcenter/panel.inc.css
+++ b/browser/themes/shared/controlcenter/panel.inc.css
@@ -98,17 +98,17 @@
   background-position: 1em 1em;
   background-size: 24px auto;
 }
 
 #identity-popup-security-content,
 #identity-popup-permissions-content,
 #tracking-protection-content {
   padding: 0.5em 0 1em;
-  /* .identity-popup-headline.host depends on this width */
+  /* .identity-popup-host depends on this width */
   padding-inline-start: calc(2em + 24px);
   padding-inline-end: 1em;
 }
 
 #identity-popup-securityView:-moz-locale-dir(rtl),
 #identity-popup-security-content:-moz-locale-dir(rtl),
 #identity-popup-permissions-content:-moz-locale-dir(rtl),
 #tracking-protection-content:-moz-locale-dir(rtl) {
@@ -177,17 +177,17 @@
   margin: 0;
 }
 
 .identity-popup-headline {
   margin: 3px 0 4px;
   font-size: 150%;
 }
 
-.identity-popup-headline.host {
+.identity-popup-host {
   word-wrap: break-word;
   /* 1em + 2em + 24px is #identity-popup-security-content padding
    * 30em is .panel-mainview:not([panelid="PanelUI-popup"]) width */
   max-width: calc(30rem - 3rem - 24px - var(--identity-popup-expander-width))
 }
 
 .identity-popup-warning-gray {
   padding-inline-start: 24px;
--- a/browser/tools/mozscreenshots/head.js
+++ b/browser/tools/mozscreenshots/head.js
@@ -9,17 +9,19 @@
 const {AddonWatcher} = Cu.import("resource://gre/modules/AddonWatcher.jsm", {});
 const chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry);
 const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
 const EXTENSION_DIR = "chrome://mochitests/content/extensions/mozscreenshots/browser/";
 
 let TestRunner;
 
 function* setup() {
-  requestLongerTimeout(10);
+  // This timeout doesn't actually end the job even if it is hit - the buildbot timeout will
+  // handle things for us if the test actually hangs.
+  requestLongerTimeout(100);
 
   info("installing extension temporarily");
   let chromeURL = Services.io.newURI(EXTENSION_DIR);
   let dir = chromeRegistry.convertChromeURL(chromeURL).QueryInterface(Ci.nsIFileURL).file;
   yield AddonManager.installTemporaryAddon(dir);
 
   info("Checking for mozscreenshots extension");
   return new Promise((resolve) => {
deleted file mode 100644
--- a/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/DevEdition.jsm
+++ /dev/null
@@ -1,42 +0,0 @@
-/* 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";
-
-this.EXPORTED_SYMBOLS = ["DevEdition"];
-
-const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
-const THEME_ID = "firefox-devedition@mozilla.org";
-
-Cu.import("resource://gre/modules/LightweightThemeManager.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/Task.jsm");
-
-this.DevEdition = {
-  init(libDir) {},
-
-  configurations: {
-    devEditionLight: {
-      applyConfig: Task.async(() => {
-        Services.prefs.setCharPref("devtools.theme", "light");
-        LightweightThemeManager.currentTheme = LightweightThemeManager.getUsedTheme(THEME_ID);
-        Services.prefs.setBoolPref("browser.devedition.theme.showCustomizeButton", true);
-      }),
-    },
-    devEditionDark: {
-      applyConfig: Task.async(() => {
-        Services.prefs.setCharPref("devtools.theme", "dark");
-        LightweightThemeManager.currentTheme = LightweightThemeManager.getUsedTheme(THEME_ID);
-        Services.prefs.setBoolPref("browser.devedition.theme.showCustomizeButton", true);
-      }),
-    },
-    devEditionOff: {
-      applyConfig: Task.async(() => {
-        Services.prefs.clearUserPref("devtools.theme");
-        LightweightThemeManager.currentTheme = null;
-        Services.prefs.clearUserPref("browser.devedition.theme.showCustomizeButton");
-      }),
-    },
-  },
-};
--- a/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/LightweightThemes.jsm
+++ b/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/LightweightThemes.jsm
@@ -46,18 +46,16 @@ this.LightweightThemes = {
 
         // Wait for LWT listener
         return new Promise(resolve => {
           setTimeout(() => {
             resolve("darkLWT");
           }, 500);
         });
       },
-
-      verifyConfig: verifyConfigHelper,
     },
 
     lightLWT: {
       applyConfig() {
         LightweightThemeManager.setLocalTheme({
           id:          "white",
           name:        "white",
           headerURL:   LightweightThemes._whiteImageURL,
@@ -67,26 +65,23 @@ this.LightweightThemes = {
         });
         // Wait for LWT listener
         return new Promise(resolve => {
           setTimeout(() => {
             resolve("lightLWT");
           }, 500);
         });
       },
-
-      verifyConfig: verifyConfigHelper,
     },
 
+    compactLight: {
+      applyConfig: Task.async(() => {
+        LightweightThemeManager.currentTheme = LightweightThemeManager.getUsedTheme("firefox-compact-light@mozilla.org");
+      }),
+    },
+
+    compactDark: {
+      applyConfig: Task.async(() => {
+        LightweightThemeManager.currentTheme = LightweightThemeManager.getUsedTheme("firefox-compact-dark@mozilla.org");
+      }),
+    },
   },
 };
-
-
-function verifyConfigHelper() {
-  return new Promise((resolve, reject) => {
-    let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
-    if (browserWindow.document.documentElement.hasAttribute("lwtheme")) {
-      resolve("verifyConfigHelper");
-    } else {
-      reject("The @lwtheme attribute wasn't present so themes may not be available");
-    }
-  });
-}
--- a/browser/tools/mozscreenshots/primaryUI/browser_primaryUI.js
+++ b/browser/tools/mozscreenshots/primaryUI/browser_primaryUI.js
@@ -6,13 +6,11 @@
 
 "use strict";
 
 add_task(function* capture() {
   if (!shouldCapture()) {
     return;
   }
 
-  requestLongerTimeout(20);
-
   let sets = ["TabsInTitlebar", "Tabs", "WindowSize", "Toolbars", "LightweightThemes"];
   yield TestRunner.start(sets, "primaryUI");
 });
--- a/caps/tests/unit/xpcshell.ini
+++ b/caps/tests/unit/xpcshell.ini
@@ -1,5 +1,4 @@
 [DEFAULT]
 head =
-tail =
 
 [test_origin.js]
--- a/chrome/test/unit/xpcshell.ini
+++ b/chrome/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_crtestutils.js
-tail =
 support-files = data/**
 
 [test_abi.js]
 [test_bug292789.js]
 [test_bug380398.js]
 [test_bug397073.js]
 [test_bug399707.js]
 [test_bug401153.js]
--- a/chrome/test/unit_ipc/xpcshell.ini
+++ b/chrome/test/unit_ipc/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head = 
-tail = 
 skip-if = toolkit == 'android'
 support-files =
   !/chrome/test/unit/data/**
   !/chrome/test/unit/test_resolve_uris.js
   !/chrome/test/unit/head_crtestutils.js
 
 [test_resolve_uris_ipc.js]
new file mode 100644
--- /dev/null
+++ b/devtools/CODE_OF_CONDUCT.md
@@ -0,0 +1,118 @@
+# Developer Tools Code of Conduct
+
+This file describes the Developer Tools (aka "DevTools") code of conduct.
+
+# Conduct
+
+We are committed to providing a friendly, safe and welcoming
+environment for all, regardless of level of experience, gender, gender
+identity and expression, sexual orientation, disability, personal
+appearance, body size, race, ethnicity, age, religion, nationality, or
+other similar characteristic.
+
+On IRC, please avoid using overtly sexual nicknames or other nicknames
+that might detract from a friendly, safe and welcoming environment for
+all.
+
+Please be kind and courteous. There’s no need to be mean or rude.
+
+Respect that people have differences of opinion and that every design
+or implementation choice carries a trade-off and numerous costs. There
+is seldom a right answer.
+
+We will exclude you from interaction if you insult, demean or harass
+anyone. That is not welcome behaviour. We interpret the term
+“harassment” as including the definition in the
+[Citizen Code of Conduct](http://citizencodeofconduct.org/); if you
+have any lack of clarity about what might be included in that concept,
+please read their definition. In particular, we don’t tolerate
+behavior that excludes people in socially marginalized groups.
+
+Private harassment is also unacceptable. No matter who you are, if you
+feel you have been or are being harassed or made uncomfortable by a
+community member, please contact one of the moderators (see below)
+immediately. Whether you’re a regular contributor or a newcomer, we
+care about making this community a safe place for you and we’ve got
+your back.
+
+Likewise any spamming, trolling, flaming, baiting or other
+attention-stealing behaviour is not welcome.
+
+# Moderation
+
+These are the policies for upholding our community’s standards of
+conduct. If you feel that a thread needs moderation, please use the
+point of contact for the medium in which you're communicating:
+
+* For one of the IRC channels, contact a channel operator (they
+  have an "@" in front of their names);
+* Bugzilla and the dev-developer-tools mailing list, contact
+  [inclusion@mozilla.com](mailto:inclusion@mozilla.com);
+* The debugger.html repository on GitHub has [its own code of conduct](https://github.com/devtools-html/debugger.html/blob/master/CODE_OF_CONDUCT.md), but you can also
+  email [inclusion@mozilla.com](mailto:inclusion@mozilla.com).
+
+Remarks that violate these standards of conduct, including hateful,
+hurtful, oppressive, or exclusionary remarks, are not
+allowed. (Cursing is allowed, but never targeting another user, and
+never in a hateful manner.)
+
+Remarks that moderators find inappropriate, whether listed in the code
+of conduct or not, are also not allowed.
+
+Moderators will first respond to such remarks with a warning.
+
+If the warning is unheeded, then on IRC the user will be “kicked,”
+i.e., kicked out of the communication channel to cool off.
+
+If the user comes back and continues to make trouble, they will be
+banned, i.e., indefinitely excluded.  On other communications media,
+such as bugzilla or mailing lists, it will be up to the moderator's
+discretion whether a user will be banned after the first warning.
+
+Moderators may choose at their discretion to un-ban the user if it was
+a first offense and they offer the offended party a genuine apology.
+
+If a moderator bans someone and you think it was unjustified, please
+take it up with that moderator, or with a different moderator, in
+private. Complaints about bans in-channel (or on the mailing list or
+in the bug tracker) are not allowed.
+
+Moderators are held to a higher standard than other community
+members. If a moderator creates an inappropriate situation, they
+should expect less leeway than others.
+
+In this community we strive to go the extra step to look out for
+each other. Don’t just aim to be technically unimpeachable, try to be
+your best self. In particular, avoid flirting with offensive or
+sensitive issues, particularly if they’re off-topic; this all too
+often leads to unnecessary fights, hurt feelings, and damaged trust;
+worse, it can drive people away from the community entirely.
+
+And if someone takes issue with something you said or did, resist the
+urge to be defensive. Just stop doing what it was they complained
+about and apologize. Even if you feel you were misinterpreted or
+unfairly accused, chances are good there was something you could’ve
+communicated better — remember that it’s your responsibility to make
+your fellow devtoolers comfortable. Everyone wants to get along and we
+are all here first and foremost because we want to talk about cool
+technology. You will find that people will be eager to assume good
+intent and forgive as long as you earn their trust.
+
+The enforcement policies listed above apply to all official DevTools
+venues; including official IRC channels (those starting with
+"#devtools"); GitHub repositories associated with DevTools; and
+DevTools bugs in bugzilla.
+
+# Also Applicable
+
+The
+[Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/)
+also apply.  Please read these as well.
+
+# History
+
+This was derived from the
+[Rust Code of Conduct](https://www.rust-lang.org/en-US/conduct.html).
+
+See [bug 1315344](https://bugzilla.mozilla.org/show_bug.cgi?id=1315344) if
+you are curious about the genesis of this document.
--- a/devtools/client/animationinspector/test/unit/xpcshell.ini
+++ b/devtools/client/animationinspector/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head =
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_findOptimalTimeInterval.js]
 [test_formatStopwatchTime.js]
 [test_getCssPropertyName.js]
 [test_timeScale.js]
 [test_timeScale_dimensions.js]
--- a/devtools/client/framework/target.js
+++ b/devtools/client/framework/target.js
@@ -138,16 +138,18 @@ function TabTarget(tab) {
   // Default isTabActor to true if not explicitly specified
   if (typeof tab.isTabActor == "boolean") {
     this._isTabActor = tab.isTabActor;
   } else {
     this._isTabActor = true;
   }
 }
 
+exports.TabTarget = TabTarget;
+
 TabTarget.prototype = {
   _webProgressListener: null,
 
   /**
    * Returns a promise for the protocol description from the root actor. Used
    * internally with `target.actorHasMethod`. Takes advantage of caching if
    * definition was fetched previously with the corresponding actor information.
    * Actors are lazily loaded, so not only must the tool using a specific actor
--- a/devtools/client/memory/test/unit/xpcshell.ini
+++ b/devtools/client/memory/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools devtools-memory
 head = head.js ../../../framework/test/shared-redux-head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_action_diffing_01.js]
 [test_action_diffing_02.js]
 [test_action_diffing_03.js]
 [test_action_diffing_04.js]
 [test_action_diffing_05.js]
--- a/devtools/client/netmonitor/shared/components/preview-panel.js
+++ b/devtools/client/netmonitor/shared/components/preview-panel.js
@@ -3,30 +3,33 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { DOM, PropTypes } = require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 const { getSelectedRequest } = require("../../selectors/index");
 
-const { iframe } = DOM;
+const { div, iframe } = DOM;
 
 /*
  * Preview panel component
  * Display HTML content within a sandbox enabled iframe
  */
 function PreviewPanel({
   srcDoc = "",
 }) {
-  return iframe({
-    id: "response-preview",
-    sandbox: "",
-    srcDoc,
-  });
+  return (
+    div({ className: "panel-container" },
+      iframe({
+        sandbox: "",
+        srcDoc,
+      })
+    )
+  );
 }
 
 PreviewPanel.displayName = "PreviewPanel";
 
 PreviewPanel.propTypes = {
   srcDoc: PropTypes.string,
 };
 
--- a/devtools/client/netmonitor/shared/components/properties-view.js
+++ b/devtools/client/netmonitor/shared/components/properties-view.js
@@ -7,26 +7,27 @@
 "use strict";
 
 const {
   createClass,
   createFactory,
   DOM,
   PropTypes,
 } = require("devtools/client/shared/vendor/react");
-const { createFactories } = require("devtools/client/shared/components/reps/rep-utils");
-const { MODE } = require("devtools/client/shared/components/reps/constants");
+
+const { REPS, MODE } = require("devtools/client/shared/components/reps/load-reps");
+const Rep = createFactory(REPS.Rep);
+
 const { FILTER_SEARCH_DELAY } = require("../../constants");
 
 // Components
 const Editor = createFactory(require("devtools/client/netmonitor/shared/components/editor"));
 const SearchBox = createFactory(require("devtools/client/shared/components/search-box"));
 const TreeView = createFactory(require("devtools/client/shared/components/tree/tree-view"));
 const TreeRow = createFactory(require("devtools/client/shared/components/tree/tree-row"));
-const { Rep } = createFactories(require("devtools/client/shared/components/reps/rep"));
 
 const { div, tr, td } = DOM;
 const AUTO_EXPAND_MAX_LEVEL = 7;
 const EDITOR_CONFIG_ID = "EDITOR_CONFIG";
 
 /*
  * Properties View component
  * A scrollable tree view component which provides some useful features for
@@ -49,16 +50,17 @@ const PropertiesView = createClass({
   },
 
   getDefaultProps() {
     return {
       enableInput: true,
       enableFilter: true,
       expandableStrings: false,
       filterPlaceHolder: "",
+      sectionNames: [],
     };
   },
 
   getInitialState() {
     return {
       filterText: "",
     };
   },
--- a/devtools/client/netmonitor/shared/components/security-panel.js
+++ b/devtools/client/netmonitor/shared/components/security-panel.js
@@ -1,22 +1,22 @@
 /* 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 { DOM, PropTypes, createFactory } = require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
-const TreeView = createFactory(require("devtools/client/shared/components/tree/tree-view"));
+const PropertiesView = createFactory(require("./properties-view"));
 const { L10N } = require("../../l10n");
 const { getUrlHost } = require("../../request-utils");
 const { getSelectedRequest } = require("../../selectors/index");
 
-const { div, input } = DOM;
+const { div, input, span } = DOM;
 
 /*
  * Security panel component
  * If the site is being served over HTTPS, you get an extra tab labeled "Security".
  * This contains details about the secure connection used including the protocol,
  * the cipher suite, and certificate details
  */
 function SecurityPanel({
@@ -82,46 +82,42 @@ function SecurityPanel({
   } else {
     object = {
       [L10N.getStr("netmonitor.security.error")]:
         new DOMParser().parseFromString(securityInfo.errorMessage, "text/html")
           .body.textContent || notAvailable
     };
   }
 
-  return div({ id: "security-information" },
-    TreeView({
+  return div({ className: "panel-container" },
+    PropertiesView({
       object,
-      columns: [{
-        id: "value",
-        width: "100%",
-      }],
-      renderValue: renderValue.bind(null, securityInfo.weaknessReasons),
+      renderValue: (props) => renderValue(props, securityInfo.weaknessReasons),
+      enableFilter: false,
       expandedNodes: getExpandedNodes(object),
-      expandableStrings: false,
     })
   );
 }
 
 SecurityPanel.displayName = "SecurityPanel";
 
 SecurityPanel.propTypes = {
   securityInfo: PropTypes.object,
   url: PropTypes.string,
 };
 
-function renderValue(weaknessReasons = [], props) {
+function renderValue(props, weaknessReasons = []) {
   const { member, value } = props;
 
   // Hide object summary
   if (typeof member.value === "object") {
     return null;
   }
 
-  return div({ className: "security-info-value" },
+  return span({ className: "security-info-value" },
     member.name === L10N.getStr("netmonitor.security.error") ?
       // Display multiline text for security error
       value
       :
       // Display one line selectable text for security details
       input({
         className: "textbox-input",
         readOnly: "true",
--- a/devtools/client/netmonitor/shared/components/timings-panel.js
+++ b/devtools/client/netmonitor/shared/components/timings-panel.js
@@ -53,17 +53,17 @@ function TimingsPanel({
         }),
         span({ className: "requests-menu-timings-total" },
           L10N.getFormatStr("networkMenu.totalMS", timings[type])
         )
       ),
     );
   });
 
-  return div({}, timelines);
+  return div({ className: "panel-container" }, timelines);
 }
 
 TimingsPanel.displayName = "TimingsPanel";
 
 TimingsPanel.propTypes = {
   timings: PropTypes.object,
   totalTime: PropTypes.number,
 };
--- a/devtools/client/netmonitor/test/browser_net_html-preview.js
+++ b/devtools/client/netmonitor/test/browser_net_html-preview.js
@@ -6,58 +6,58 @@
 /**
  * Tests if html responses show and properly populate a "Preview" tab.
  */
 
 add_task(function* () {
   let { tab, monitor } = yield initNetMonitor(CONTENT_TYPE_URL);
   info("Starting test... ");
 
-  let { $, document, EVENTS, NetMonitorView } = monitor.panelWin;
+  let { document, NetMonitorView } = monitor.panelWin;
   let { RequestsMenu } = NetMonitorView;
 
   RequestsMenu.lazyUpdate = false;
 
   let wait = waitForNetworkEvents(monitor, 6);
   yield ContentTask.spawn(tab.linkedBrowser, {}, function* () {
     content.wrappedJSObject.performRequests();
   });
   yield wait;
 
   EventUtils.sendMouseEvent({ type: "mousedown" },
     document.getElementById("details-pane-toggle"));
 
-  is($("#event-details-pane").selectedIndex, 0,
+  is(document.querySelector("#event-details-pane").selectedIndex, 0,
     "The first tab in the details pane should be selected.");
-  is($("#preview-tab").hidden, true,
+  is(document.querySelector("#preview-tab").hidden, true,
     "The preview tab should be hidden for non html responses.");
-  is($("#preview-tabpanel").hidden, false,
+  is(document.querySelector("#preview-tabpanel").hidden, false,
     "The preview tabpanel is not hidden for non html responses.");
 
   RequestsMenu.selectedIndex = 4;
   NetMonitorView.toggleDetailsPane({ visible: true, animated: false }, 6);
 
-  is($("#event-details-pane").selectedIndex, 6,
+  is(document.querySelector("#event-details-pane").selectedIndex, 6,
     "The sixth tab in the details pane should be selected.");
-  is($("#preview-tab").hidden, false,
+  is(document.querySelector("#preview-tab").hidden, false,
     "The preview tab should be visible now.");
 
-  let iframe = $("#response-preview");
+  let iframe = document.querySelector("#preview-tabpanel iframe");
   yield once(iframe, "DOMContentLoaded");
 
   ok(iframe,
     "There should be a response preview iframe available.");
   ok(iframe.contentDocument,
     "The iframe's content document should be available.");
   is(iframe.contentDocument.querySelector("blink").textContent, "Not Found",
     "The iframe's content document should be loaded and correct.");
 
   RequestsMenu.selectedIndex = 5;
 
-  is($("#event-details-pane").selectedIndex, 0,
+  is(document.querySelector("#event-details-pane").selectedIndex, 0,
     "The first tab in the details pane should be selected again.");
-  is($("#preview-tab").hidden, true,
+  is(document.querySelector("#preview-tab").hidden, true,
     "The preview tab should be hidden again for non html responses.");
-  is($("#preview-tabpanel").hidden, false,
+  is(document.querySelector("#preview-tabpanel").hidden, false,
     "The preview tabpanel is not hidden again for non html responses.");
 
   yield teardown(monitor);
 });
--- a/devtools/client/netmonitor/test/browser_net_security-details.js
+++ b/devtools/client/netmonitor/test/browser_net_security-details.js
@@ -24,19 +24,16 @@ add_task(function* () {
 
   wait = waitForDOM(document, "#security-tabpanel");
   EventUtils.sendMouseEvent({ type: "mousedown" },
     document.getElementById("details-pane-toggle"));
   EventUtils.sendMouseEvent({ type: "mousedown" },
     document.querySelectorAll("#details-pane tab")[5]);
   yield wait;
 
-  is(document.querySelector("#security-error"), null, "Error box is hidden.");
-  ok(document.querySelector("#security-information"), "Information box visible.");
-
   let tabpanel = document.querySelector("#security-tabpanel");
   let textboxes = tabpanel.querySelectorAll(".textbox-input");
 
   // Connection
   // The protocol will be TLS but the exact version depends on which protocol
   // the test server example.com supports.
   let protocol = textboxes[0].value;
   ok(protocol.startsWith("TLS"), "The protocol " + protocol + " seems valid.");
--- a/devtools/client/performance/test/unit/xpcshell.ini
+++ b/devtools/client/performance/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head = head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_frame-utils-01.js]
 [test_frame-utils-02.js]
 [test_marker-blueprint.js]
 [test_marker-utils.js]
 [test_profiler-categories.js]
--- a/devtools/client/responsive.html/test/unit/xpcshell.ini
+++ b/devtools/client/responsive.html/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head = head.js ../../../framework/test/shared-redux-head.js
-tail =
 firefox-appdir = browser
 
 [test_add_device.js]
 [test_add_device_type.js]
 [test_add_viewport.js]
 [test_change_display_pixel_ratio.js]
 [test_change_location.js]
 [test_change_network_throttling.js]
--- a/devtools/client/shared/redux/middleware/test/xpcshell.ini
+++ b/devtools/client/shared/redux/middleware/test/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 tags = devtools
 head = head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_middleware-task-01.js]
 [test_middleware-task-02.js]
 [test_middleware-task-03.js]
--- a/devtools/client/shared/test/browser_theme.js
+++ b/devtools/client/shared/test/browser_theme.js
@@ -27,31 +27,34 @@ function testGetTheme() {
   is(getTheme(), "firebug", "getTheme() correctly returns firebug theme");
   Services.prefs.setCharPref("devtools.theme", "unknown");
   is(getTheme(), "unknown", "getTheme() correctly returns an unknown theme");
   Services.prefs.setCharPref("devtools.theme", originalTheme);
 }
 
 function testSetTheme() {
   let originalTheme = getTheme();
+  // Put this in a variable rather than hardcoding it because the default
+  // changes between aurora and nightly
+  let otherTheme = originalTheme == "dark" ? "light" : "dark";
 
   let prefObserver = new PrefObserver("devtools.");
   prefObserver.once("devtools.theme", pref => {
     is(pref, "devtools.theme",
       "A preference event triggered by setTheme has correct pref.");
     let newValue = Services.prefs.getCharPref("devtools.theme");
-    is(newValue, "dark",
+    is(newValue, otherTheme,
       "A preference event triggered by setTheme comes after the value is set.");
   });
-  setTheme("dark");
-  is(Services.prefs.getCharPref("devtools.theme"), "dark",
-     "setTheme() correctly sets dark theme.");
-  setTheme("light");
-  is(Services.prefs.getCharPref("devtools.theme"), "light",
-     "setTheme() correctly sets light theme.");
+  setTheme(otherTheme);
+  is(Services.prefs.getCharPref("devtools.theme"), otherTheme,
+     "setTheme() correctly sets another theme.");
+  setTheme(originalTheme);
+  is(Services.prefs.getCharPref("devtools.theme"), originalTheme,
+     "setTheme() correctly sets the original theme.");
   setTheme("firebug");
   is(Services.prefs.getCharPref("devtools.theme"), "firebug",
      "setTheme() correctly sets firebug theme.");
   setTheme("unknown");
   is(Services.prefs.getCharPref("devtools.theme"), "unknown",
      "setTheme() correctly sets an unknown theme.");
   Services.prefs.setCharPref("devtools.theme", originalTheme);
 
--- a/devtools/client/shared/test/unit/xpcshell.ini
+++ b/devtools/client/shared/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head =
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 support-files =
   ../helper_color_data.js
 
 [test_advanceValidate.js]
 [test_attribute-parsing-01.js]
--- a/devtools/client/sourceeditor/tern/tests/unit/xpcshell.ini
+++ b/devtools/client/sourceeditor/tern/tests/unit/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 tags = devtools
 head = head_tern.js
-tail =
 firefox-appdir = browser
 
 [test_autocompletion.js]
 [test_import_tern.js]
--- a/devtools/client/themes/netmonitor.css
+++ b/devtools/client/themes/netmonitor.css
@@ -706,41 +706,26 @@
   color: var(--theme-selection-color);
 }
 
 .response-image-box {
   display: flex;
   flex-direction: column;
   justify-content: center;
   align-items: center;
-  /* Minus 24px * 2 for toolbox height + tabpanel height + padding top + padding bottom  */
-  height: calc(100vh - 68px);
   overflow-y: auto;
   padding: 10px;
 }
 
 .response-image {
   background: #fff;
   border: 1px dashed GrayText;
   margin-bottom: 10px;
-  max-width: 100%;
-  max-height: 100%;
-}
-
-/* Preview tabpanel */
-
-#preview-tabpanel {
-  background: #fff;
-}
-
-#response-preview {
-  border: none;
-  display: -moz-box;
-  -moz-box-orient: vertical;
-  -moz-box-flex: 1;
+  max-width: 300px;
+  max-height: 100px;
 }
 
 /* Timings tabpanel */
 
 #timings-tabpanel .tabpanel-summary-container {
   display: flex;
 }
 
@@ -764,27 +749,26 @@
   transition: width 0.2s ease-out;
 }
 
 .theme-firebug #timings-tabpanel .requests-menu-timings-total {
   color: var(--theme-body-color);
 }
 
 /* Security tabpanel */
-.security-info-section {
-  padding-inline-start: 1em;
+
+/* Overwrite tree-view cell colon `:` for security panel and tree section */
+#security-tabpanel .treeTable .treeLabelCell::after,
+.treeTable .tree-section .treeLabelCell::after {
+  content: "";
 }
 
-.theme-dark #security-error-message {
-  color: var(--theme-selection-color);
-}
-
-#security-tabpanel {
-  overflow: auto;
-  -moz-user-select: text;
+/* Layout additional warning icon in tree value cell  */
+.security-info-value {
+  display: flex;
 }
 
 .security-warning-icon {
   background-image: url(images/alerticon-warning.png);
   background-size: 13px 12px;
   margin-inline-start: 5px;
   vertical-align: top;
   width: 13px;
@@ -1077,27 +1061,16 @@
 
 /* Responsive sidebar */
 @media (max-width: 700px) {
   :root[platform="linux"] .requests-menu-header-button {
     font-size: 85%;
   }
 }
 
-/* Overwrite tree-view cell colon `:` for security panel and tree section */
-#security-tabpanel .treeTable .treeLabelCell::after,
-.treeTable .tree-section .treeLabelCell::after {
-  content: "";
-}
-
-/* Layout additional warning icon in tree value cell  */
-.security-info-value {
-  display: flex;
-}
-
 .textbox-input {
   text-overflow: ellipsis;
   border: none;
   background: none;
   color: inherit;
   width: 100%;
 }
 
@@ -1145,17 +1118,17 @@
 .tree-container .treeTable tbody {
   display: flex;
   flex-direction: column;
   /* Apply flex to table will create an anonymous table element outside of tbody
    * See also http://stackoverflow.com/a/30851678
    * Therefore, we set height with this magic number in order to remove the
    * redundant scrollbar when source editor appears.
    */
-  height: calc(100% - 3px);
+  height: calc(100% - 4px);
 }
 
 .tree-container .treeTable tr {
   display: block;
 }
 
 /* Make right td fill available horizontal space */
 .tree-container .treeTable td:last-child {
@@ -1172,18 +1145,22 @@
 }
 
 .properties-view .devtools-searchbox,
 .tree-container .treeTable .tree-section {
   width: 100%;
   background-color: var(--theme-toolbar-background);
 }
 
+.tree-container .treeTable tr.tree-section:not(:first-child) td:not([class=""]) {
+  border-top: 1px solid var(--theme-splitter-color);
+}
+
 .properties-view .devtools-searchbox,
-.tree-container .treeTable tr:not(:last-child) td:not([class=""]) {
+.tree-container .treeTable tr.tree-section:not(:last-child) td:not([class=""]) {
   border-bottom: 1px solid var(--theme-splitter-color);
 }
 
 .tree-container .treeTable .tree-section > * {
   vertical-align: middle;
 }
 
 .tree-container .treeTable .treeRow.tree-section > .treeLabelCell > .treeLabel,
@@ -1286,17 +1263,17 @@
 }
 
 .response-summary {
   display: flex;
 }
 
 .editor-container,
 .editor-mount,
-.editor-mount iframe {
+.panel-container iframe {
   border: none;
   width: 100%;
   height: 100%;
 }
 
 /*
  * FIXME: normal html block element cannot fill outer XUL element
  * This workaround should be removed after netmonitor is migrated to react
--- a/devtools/client/webconsole/net/test/unit/xpcshell.ini
+++ b/devtools/client/webconsole/net/test/unit/xpcshell.ini
@@ -1,9 +1,8 @@
 [DEFAULT]
 tags = devtools
 head =
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_json-utils.js]
 [test_net-utils.js]
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head = head_dbg.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 support-files =
   babel_and_browserify_script_with_source_map.js
   source-map-data/sourcemapped.coffee
   source-map-data/sourcemapped.map
   post_init_global_actors.js
--- a/devtools/shared/acorn/tests/unit/xpcshell.ini
+++ b/devtools/shared/acorn/tests/unit/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 tags = devtools
 head = head_acorn.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_import_acorn.js]
 [test_same_ast.js]
 [test_lenient_parser.js]
--- a/devtools/shared/discovery/tests/unit/xpcshell.ini
+++ b/devtools/shared/discovery/tests/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 tags = devtools
 head =
-tail =
 firefox-appdir = browser
 
 [test_discovery.js]
--- a/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini
+++ b/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools heapsnapshot devtools-memory
 head = head_heapsnapshot.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 support-files =
   Census.jsm
   dominator-tree-worker.js
   heap-snapshot-worker.js
   Match.jsm
--- a/devtools/shared/jsbeautify/tests/unit/xpcshell.ini
+++ b/devtools/shared/jsbeautify/tests/unit/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 tags = devtools
 head = head_jsbeautify.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test.js]
--- a/devtools/shared/performance/test/xpcshell.ini
+++ b/devtools/shared/performance/test/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 tags = devtools
 head = head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_perf-utils-allocations-to-samples.js]
--- a/devtools/shared/platform/content/test/xpcshell.ini
+++ b/devtools/shared/platform/content/test/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 tags = devtools
 head =
-tail =
 firefox-appdir = browser
 
 [test_stack.js]
--- a/devtools/shared/pretty-fast/tests/unit/xpcshell.ini
+++ b/devtools/shared/pretty-fast/tests/unit/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 tags = devtools
 head = head_pretty-fast.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test.js]
--- a/devtools/shared/qrcode/tests/unit/xpcshell.ini
+++ b/devtools/shared/qrcode/tests/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 tags = devtools
 head =
-tail =
 firefox-appdir = browser
 
 [test_encode.js]
--- a/devtools/shared/security/tests/unit/xpcshell.ini
+++ b/devtools/shared/security/tests/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head = head_dbg.js
-tail =
 firefox-appdir = browser
 
 support-files=
   testactors.js
 
 [test_encryption.js]
 [test_oob_cert_auth.js]
 skip-if = (toolkit == 'android' && !debug) # Bug 1141544: Re-enable when buildbot tests are gone
--- a/devtools/shared/sourcemap/tests/unit/xpcshell.ini
+++ b/devtools/shared/sourcemap/tests/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head = head_sourcemap.js
-tail =
 
 [test_util.js]
 [test_source_node.js]
 [test_source_map_generator.js]
 [test_source_map_consumer.js]
 [test_quick_sort.js]
 [test_dog_fooding.js]
 [test_binary_search.js]
--- a/devtools/shared/tests/unit/xpcshell.ini
+++ b/devtools/shared/tests/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head = head_devtools.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 support-files =
   exposeLoader.js
 
 [test_assert.js]
 [test_csslexer.js]
 [test_css-properties-db.js]
--- a/devtools/shared/transport/tests/unit/xpcshell.ini
+++ b/devtools/shared/transport/tests/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head = head_dbg.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 support-files =
   testactors.js
   testactors-no-bulk.js
 
 [test_bulk_error.js]
--- a/devtools/shared/webconsole/test/unit/xpcshell.ini
+++ b/devtools/shared/webconsole/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 tags = devtools
 head =
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 support-files =
 
 [test_js_property_provider.js]
 [test_network_helper.js]
 [test_security-info-certificate.js]
 [test_security-info-parser.js]
--- a/docshell/test/unit/xpcshell.ini
+++ b/docshell/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_docshell.js
-tail = 
 
 [test_bug414201_jfif.js]
 [test_bug442584.js]
 [test_nsDefaultURIFixup.js]
 [test_nsDefaultURIFixup_search.js]
 skip-if = os == 'android'
 [test_nsDefaultURIFixup_info.js]
 skip-if = os == 'android'
--- a/docshell/test/unit_ipc/xpcshell.ini
+++ b/docshell/test/unit_ipc/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 
 [test_pb_notification_ipc.js]
 # Bug 751575: Perma-fails with: command timed out: 1200 seconds without output
 skip-if = true
--- a/dom/base/CustomElementRegistry.cpp
+++ b/dom/base/CustomElementRegistry.cpp
@@ -767,32 +767,33 @@ CustomElementRegistry::Define(const nsAS
    * 11. Let definition be a new custom element definition with name name,
    *     local name localName, constructor constructor, prototype prototype,
    *     observed attributes observedAttributes, and lifecycle callbacks
    *     lifecycleCallbacks.
    */
   // Associate the definition with the custom element.
   nsCOMPtr<nsIAtom> localNameAtom(NS_Atomize(localName));
   LifecycleCallbacks* callbacks = callbacksHolder.forget();
-  CustomElementDefinition* definition =
-    new CustomElementDefinition(nameAtom,
-                                localNameAtom,
-                                constructor,
-                                constructorPrototype,
-                                callbacks,
-                                0 /* TODO dependent on HTML imports. Bug 877072 */);
 
   /**
    * 12. Add definition to this CustomElementRegistry.
    */
   if (!mConstructors.put(constructorUnwrapped, nameAtom)) {
     aRv.Throw(NS_ERROR_FAILURE);
     return;
   }
 
+  CustomElementDefinition* definition =
+    new CustomElementDefinition(nameAtom,
+                                localNameAtom,
+                                constructor,
+                                constructorPrototype,
+                                callbacks,
+                                0 /* TODO dependent on HTML imports. Bug 877072 */);
+
   mCustomDefinitions.Put(nameAtom, definition);
 
   MOZ_ASSERT(mCustomDefinitions.Count() == mConstructors.count(),
              "Number of entries should be the same");
 
   /**
    * 13. 14. 15. Upgrade candidates
    */
new file mode 100644
--- /dev/null
+++ b/dom/base/crashtests/1326194-1.html
@@ -0,0 +1,20 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<script type="application/javascript">
+
+// Crashes if 'target' doesn't get properly unlinked in nsNodeUtils::LastRelease
+
+function crash() {
+	var target = document.getElementById('target');
+	var io = new IntersectionObserver(function () { }, { });
+	io.observe(target);
+	document.body.removeChild(target);
+}
+
+</script>
+</head>
+<body onload="crash()">
+	<div id="target" style="background: red; width: 50px; height: 50px"></div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/dom/base/crashtests/1326194-2.html
@@ -0,0 +1,20 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<script type="application/javascript">
+
+// Crashes if 'target' doesn't get properly unlinked in FragmentOrElement::Unlink
+
+function crash() {
+	var target = document.createElement('div');
+	// By setting a custom prop we create a cycle between JS and C++ that requires the CC to break.
+	target.foo = 'bar';
+	var io = new IntersectionObserver(function () { }, { });
+	io.observe(target);
+}
+
+</script>
+</head>
+<body onload="crash()">
+</body>
+</html>
--- a/dom/base/crashtests/crashtests.list
+++ b/dom/base/crashtests/crashtests.list
@@ -204,8 +204,10 @@ load 1181619.html
 load structured_clone_container_throws.html
 HTTP(..) load xhr_abortinprogress.html
 load xhr_empty_datauri.html
 load xhr_html_nullresponse.html
 load 1230422.html
 load 1251361.html
 load 1304437.html
 pref(dom.IntersectionObserver.enabled,true) load 1324209.html
+pref(dom.IntersectionObserver.enabled,true) load 1326194-1.html
+pref(dom.IntersectionObserver.enabled,true) load 1326194-2.html
--- a/dom/base/nsNodeUtils.cpp
+++ b/dom/base/nsNodeUtils.cpp
@@ -292,16 +292,26 @@ nsNodeUtils::LastRelease(nsINode* aNode)
   nsINode::nsSlots* slots = aNode->GetExistingSlots();
   if (slots) {
     if (!slots->mMutationObservers.IsEmpty()) {
       NS_OBSERVER_ARRAY_NOTIFY_OBSERVERS(slots->mMutationObservers,
                                          nsIMutationObserver,
                                          NodeWillBeDestroyed, (aNode));
     }
 
+    if (aNode->IsElement()) {
+      Element* elem = aNode->AsElement();
+      FragmentOrElement::nsDOMSlots* domSlots =
+        static_cast<FragmentOrElement::nsDOMSlots*>(slots);
+      for (auto iter = domSlots->mRegisteredIntersectionObservers.Iter(); !iter.Done(); iter.Next()) {
+        DOMIntersectionObserver* observer = iter.Key();
+        observer->UnlinkTarget(*elem);
+      }
+    }
+
     delete slots;
     aNode->mSlots = nullptr;
   }
 
   // Kill properties first since that may run external code, so we want to
   // be in as complete state as possible at that time.
   if (aNode->IsNodeOfType(nsINode::eDOCUMENT)) {
     // Delete all properties before tearing down the document. Some of the
--- a/dom/base/test/unit/xpcshell.ini
+++ b/dom/base/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_utilities.js
-tail =
 support-files =
   1_original.xml
   1_result.xml
   2_original.xml
   2_result_1.xml
   2_result_2.xml
   2_result_3.xml
   2_result_4.xml
--- a/dom/base/test/unit_ipc/xpcshell.ini
+++ b/dom/base/test/unit_ipc/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 support-files =
   !/dom/base/test/unit/test_bug553888.js
   !/dom/base/test/unit/test_xhr_document.js
 
 [test_bug553888_wrap.js]
 [test_xhr_document_ipc.js]
--- a/dom/cache/test/xpcshell/xpcshell.ini
+++ b/dom/cache/test/xpcshell/xpcshell.ini
@@ -1,15 +1,14 @@
 # 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/.
 
 [DEFAULT]
 head = head.js
-tail =
 support-files =
   schema_15_profile.zip
 
 # dummy test entry to generate profile zip files
 [make_profile.js]
   skip-if = true
 
 [test_migration.js]
--- a/dom/encoding/test/unit/xpcshell.ini
+++ b/dom/encoding/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 
 [test_big5.js]
 [test_euc-jp.js]
 [test_euc-kr.js]
 [test_gbk.js]
 [test_iso-2022-jp.js]
 [test_misc.js]
 [test_shift_jis.js]
--- a/dom/ipc/ContentChild.cpp
+++ b/dom/ipc/ContentChild.cpp
@@ -1901,16 +1901,17 @@ ContentChild::DeallocPPSMContentDownload
 PExternalHelperAppChild*
 ContentChild::AllocPExternalHelperAppChild(const OptionalURIParams& uri,
                                            const nsCString& aMimeContentType,
                                            const nsCString& aContentDisposition,
                                            const uint32_t& aContentDispositionHint,
                                            const nsString& aContentDispositionFilename,
                                            const bool& aForceSave,
                                            const int64_t& aContentLength,
+                                           const bool& aWasFileChannel,
                                            const OptionalURIParams& aReferrer,
                                            PBrowserChild* aBrowser)
 {
   auto *child = new ExternalHelperAppChild();
   child->AddRef();
   return child;
 }
 
--- a/dom/ipc/ContentChild.h
+++ b/dom/ipc/ContentChild.h
@@ -303,16 +303,17 @@ public:
   virtual PExternalHelperAppChild*
   AllocPExternalHelperAppChild(const OptionalURIParams& uri,
                                const nsCString& aMimeContentType,
                                const nsCString& aContentDisposition,
                                const uint32_t& aContentDispositionHint,
                                const nsString& aContentDispositionFilename,
                                const bool& aForceSave,
                                const int64_t& aContentLength,
+                               const bool& aWasFileChannel,
                                const OptionalURIParams& aReferrer,
                                PBrowserChild* aBrowser) override;
 
   virtual bool
   DeallocPExternalHelperAppChild(PExternalHelperAppChild *aService) override;
 
   virtual PHandlerServiceChild* AllocPHandlerServiceChild() override;
 
--- a/dom/ipc/ContentParent.cpp
+++ b/dom/ipc/ContentParent.cpp
@@ -2974,20 +2974,22 @@ ContentParent::DeallocPPSMContentDownloa
 PExternalHelperAppParent*
 ContentParent::AllocPExternalHelperAppParent(const OptionalURIParams& uri,
                                              const nsCString& aMimeContentType,
                                              const nsCString& aContentDisposition,
                                              const uint32_t& aContentDispositionHint,
                                              const nsString& aContentDispositionFilename,
                                              const bool& aForceSave,
                                              const int64_t& aContentLength,
+                                             const bool& aWasFileChannel,
                                              const OptionalURIParams& aReferrer,
                                              PBrowserParent* aBrowser)
 {
-  ExternalHelperAppParent *parent = new ExternalHelperAppParent(uri, aContentLength);
+  ExternalHelperAppParent *parent =
+    new ExternalHelperAppParent(uri, aContentLength, aWasFileChannel);
   parent->AddRef();
   parent->Init(this,
                aMimeContentType,
                aContentDisposition,
                aContentDispositionHint,
                aContentDispositionFilename,
                aForceSave,
                aReferrer,
--- a/dom/ipc/ContentParent.h
+++ b/dom/ipc/ContentParent.h
@@ -821,16 +821,17 @@ private:
   virtual PExternalHelperAppParent*
   AllocPExternalHelperAppParent(const OptionalURIParams& aUri,
                                 const nsCString& aMimeContentType,
                                 const nsCString& aContentDisposition,
                                 const uint32_t& aContentDispositionHint,
                                 const nsString& aContentDispositionFilename,
                                 const bool& aForceSave,
                                 const int64_t& aContentLength,
+                                const bool& aWasFileChannel,
                                 const OptionalURIParams& aReferrer,
                                 PBrowserParent* aBrowser) override;
 
   virtual bool
   DeallocPExternalHelperAppParent(PExternalHelperAppParent* aService) override;
 
   virtual PHandlerServiceParent* AllocPHandlerServiceParent() override;
 
--- a/dom/ipc/PContent.ipdl
+++ b/dom/ipc/PContent.ipdl
@@ -854,16 +854,17 @@ parent:
 
     async PExternalHelperApp(OptionalURIParams uri,
                              nsCString aMimeContentType,
                              nsCString aContentDisposition,
                              uint32_t aContentDispositionHint,
                              nsString aContentDispositionFilename,
                              bool aForceSave,
                              int64_t aContentLength,
+                             bool aWasFileChannel,
                              OptionalURIParams aReferrer,
                              nullable PBrowser aBrowser);
 
     async PHandlerService();
 
     async AddGeolocationListener(Principal principal, bool highAccuracy);
     async RemoveGeolocationListener();
     async SetGeolocationHigherAccuracy(bool enable);
--- a/dom/json/test/unit/xpcshell.ini
+++ b/dom/json/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 support-files =
   decodeFromStream-01.json
   decodeFromStream-small.json
 
 [test_decode_long_input.js]
 [test_decodeFromStream.js]
 [test_encode.js]
 
--- a/dom/media/GetUserMediaRequest.cpp
+++ b/dom/media/GetUserMediaRequest.cpp
@@ -20,16 +20,28 @@ GetUserMediaRequest::GetUserMediaRequest
   : mInnerWindowID(aInnerWindow->WindowID())
   , mOuterWindowID(aInnerWindow->GetOuterWindow()->WindowID())
   , mCallID(aCallID)
   , mConstraints(new MediaStreamConstraints(aConstraints))
   , mIsSecure(aIsSecure)
 {
 }
 
+GetUserMediaRequest::GetUserMediaRequest(
+    nsPIDOMWindowInner* aInnerWindow,
+    const nsAString& aRawId,
+    const nsAString& aMediaSource)
+  : mRawID(aRawId)
+  , mMediaSource(aMediaSource)
+{
+  if (aInnerWindow && aInnerWindow->GetOuterWindow()) {
+    mOuterWindowID = aInnerWindow->GetOuterWindow()->WindowID();
+  }
+}
+
 NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_0(GetUserMediaRequest)
 NS_IMPL_CYCLE_COLLECTING_ADDREF(GetUserMediaRequest)
 NS_IMPL_CYCLE_COLLECTING_RELEASE(GetUserMediaRequest)
 NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(GetUserMediaRequest)
   NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
   NS_INTERFACE_MAP_ENTRY(nsISupports)
 NS_INTERFACE_MAP_END
 
@@ -44,16 +56,26 @@ nsISupports* GetUserMediaRequest::GetPar
   return nullptr;
 }
 
 void GetUserMediaRequest::GetCallID(nsString& retval)
 {
   retval = mCallID;
 }
 
+void GetUserMediaRequest::GetRawID(nsString& retval)
+{
+  retval = mRawID;
+}
+
+void GetUserMediaRequest::GetMediaSource(nsString& retval)
+{
+  retval = mMediaSource;
+}
+
 uint64_t GetUserMediaRequest::WindowID()
 {
   return mOuterWindowID;
 }
 
 uint64_t GetUserMediaRequest::InnerWindowID()
 {
   return mInnerWindowID;
--- a/dom/media/GetUserMediaRequest.h
+++ b/dom/media/GetUserMediaRequest.h
@@ -19,34 +19,41 @@ struct MediaStreamConstraints;
 
 class GetUserMediaRequest : public nsISupports, public nsWrapperCache
 {
 public:
   GetUserMediaRequest(nsPIDOMWindowInner* aInnerWindow,
                       const nsAString& aCallID,
                       const MediaStreamConstraints& aConstraints,
                       bool aIsSecure);
+  GetUserMediaRequest(nsPIDOMWindowInner* aInnerWindow,
+                      const nsAString& aRawId,
+                      const nsAString& aMediaSource);
 
   NS_DECL_CYCLE_COLLECTING_ISUPPORTS
   NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(GetUserMediaRequest)
 
   JSObject* WrapObject(JSContext* cx, JS::Handle<JSObject*> aGivenProto) override;
   nsISupports* GetParentObject();
 
   uint64_t WindowID();
   uint64_t InnerWindowID();
   bool IsSecure();
   void GetCallID(nsString& retval);
+  void GetRawID(nsString& retval);
+  void GetMediaSource(nsString& retval);
   void GetConstraints(MediaStreamConstraints &result);
 
 private:
   virtual ~GetUserMediaRequest() {}
 
   uint64_t mInnerWindowID, mOuterWindowID;
   const nsString mCallID;
+  const nsString mRawID;
+  const nsString mMediaSource;
   nsAutoPtr<MediaStreamConstraints> mConstraints;
   bool mIsSecure;
 };
 
 } // namespace dom
 } // namespace mozilla
 
 #endif // GetUserMediaRequest_h__
--- a/dom/media/MediaManager.cpp
+++ b/dom/media/MediaManager.cpp
@@ -170,16 +170,17 @@ HostIsHttps(nsIURI &docURI)
 
 /**
  * This class is an implementation of MediaStreamListener. This is used
  * to Start() and Stop() the underlying MediaEngineSource when MediaStreams
  * are assigned and deassigned in content.
  */
 class GetUserMediaCallbackMediaStreamListener : public MediaStreamListener
 {
+  friend MediaManager;
 public:
   // Create in an inactive state
   GetUserMediaCallbackMediaStreamListener(base::Thread *aThread,
     uint64_t aWindowID,
     const PrincipalHandle& aPrincipalHandle)
     : mMediaThread(aThread)
     , mMainThreadCheck(nullptr)
     , mWindowID(aWindowID)
@@ -2744,25 +2745,107 @@ MediaManager::RemoveWindowID(uint64_t aW
 }
 
 void
 MediaManager::RemoveFromWindowList(uint64_t aWindowID,
   GetUserMediaCallbackMediaStreamListener *aListener)
 {
   MOZ_ASSERT(NS_IsMainThread());
 
+  nsString videoRawId;
+  nsString audioRawId;
+  nsString videoSourceType;
+  nsString audioSourceType;
+  bool hasVideoDevice = aListener->mVideoDevice;
+  bool hasAudioDevice = aListener->mAudioDevice;
+
+  if (hasVideoDevice) {
+    aListener->mVideoDevice->GetRawId(videoRawId);
+    aListener->mVideoDevice->GetMediaSource(videoSourceType);
+  }
+  if (hasAudioDevice) {
+    aListener->mAudioDevice->GetRawId(audioRawId);
+    aListener->mAudioDevice->GetMediaSource(audioSourceType);
+  }
+
   // This is defined as safe on an inactive GUMCMSListener
   aListener->Remove(); // really queues the remove
 
   StreamListeners* listeners = GetWindowListeners(aWindowID);
   if (!listeners) {
+    nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+    auto* globalWindow = nsGlobalWindow::GetInnerWindowWithId(aWindowID);
+    RefPtr<nsPIDOMWindowInner> window = globalWindow ? globalWindow->AsInner()
+                                                     : nullptr;
+    if (window != nullptr) {
+      RefPtr<GetUserMediaRequest> req =
+        new GetUserMediaRequest(window, NullString(), NullString());
+      obs->NotifyObservers(req, "recording-device-stopped", nullptr);
+    }
     return;
   }
   listeners->RemoveElement(aListener);
-  if (listeners->Length() == 0) {
+
+  uint32_t length = listeners->Length();
+
+  if (hasVideoDevice) {
+    bool revokeVideoPermission = true;
+
+    for (uint32_t i = 0; i < length; ++i) {
+      RefPtr<GetUserMediaCallbackMediaStreamListener> listener =
+        listeners->ElementAt(i);
+      if (hasVideoDevice && listener->mVideoDevice) {
+        nsString rawId;
+        listener->mVideoDevice->GetRawId(rawId);
+        if (videoRawId.Equals(rawId)) {
+          revokeVideoPermission = false;
+          break;
+        }
+      }
+    }
+
+    if (revokeVideoPermission) {
+      nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+      auto* globalWindow = nsGlobalWindow::GetInnerWindowWithId(aWindowID);
+      RefPtr<nsPIDOMWindowInner> window = globalWindow ? globalWindow->AsInner()
+                                                       : nullptr;
+      RefPtr<GetUserMediaRequest> req =
+        new GetUserMediaRequest(window, videoRawId, videoSourceType);
+      obs->NotifyObservers(req, "recording-device-stopped", nullptr);
+    }
+  }
+
+  if (hasAudioDevice) {
+    bool revokeAudioPermission = true;
+
+    for (uint32_t i = 0; i < length; ++i) {
+      RefPtr<GetUserMediaCallbackMediaStreamListener> listener =
+        listeners->ElementAt(i);
+      if (hasAudioDevice && listener->mAudioDevice) {
+        nsString rawId;
+        listener->mAudioDevice->GetRawId(rawId);
+        if (audioRawId.Equals(rawId)) {
+          revokeAudioPermission = false;
+          break;
+        }
+      }
+    }
+
+    if (revokeAudioPermission) {
+      nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+      auto* globalWindow = nsGlobalWindow::GetInnerWindowWithId(aWindowID);
+      RefPtr<nsPIDOMWindowInner> window = globalWindow ? globalWindow->AsInner()
+                                                       : nullptr;
+      RefPtr<GetUserMediaRequest> req =
+        new GetUserMediaRequest(window, audioRawId, audioSourceType);
+      obs->NotifyObservers(req, "recording-device-stopped", nullptr);
+    }
+  }
+
+  if (length == 0) {
     RemoveWindowID(aWindowID);
     // listeners has been deleted here
   }
 }
 
 void
 MediaManager::GetPref(nsIPrefBranch *aBranch, const char *aPref,
                       const char *aData, int32_t *aVal)
--- a/dom/media/encoder/TrackEncoder.cpp
+++ b/dom/media/encoder/TrackEncoder.cpp
@@ -274,44 +274,101 @@ VideoTrackEncoder::NotifyQueuedTrackChan
 
 nsresult
 VideoTrackEncoder::AppendVideoSegment(const VideoSegment& aSegment)
 {
   ReentrantMonitorAutoEnter mon(mReentrantMonitor);
 
   // Append all video segments from MediaStreamGraph, including null an
   // non-null frames.
-  VideoSegment::ChunkIterator iter(const_cast<VideoSegment&>(aSegment));
-  while (!iter.IsEnded()) {
+  VideoSegment::ConstChunkIterator iter(aSegment);
+  for (; !iter.IsEnded(); iter.Next()) {
     VideoChunk chunk = *iter;
-    mLastFrameDuration += chunk.GetDuration();
-    // Send only the unique video frames for encoding.
-    // Or if we got the same video chunks more than 1 seconds,
-    // force to send into encoder.
-    if ((mLastFrame != chunk.mFrame) ||
-        (mLastFrameDuration >= mTrackRate)) {
-      RefPtr<layers::Image> image = chunk.mFrame.GetImage();
+
+    if (mLastChunk.mTimeStamp.IsNull()) {
+      if (chunk.IsNull()) {
+        // The start of this track is frameless. We need to track the time
+        // it takes to get the first frame.
+        mLastChunk.mDuration += chunk.mDuration;
+        continue;
+      }
+
+      // This is the first real chunk in the track. Use its timestamp as the
+      // starting point for this track.
+      MOZ_ASSERT(!chunk.mTimeStamp.IsNull());
+      const StreamTime nullDuration = mLastChunk.mDuration;
+      mLastChunk = chunk;
+
+      TRACK_LOG(LogLevel::Verbose,
+                ("[VideoTrackEncoder]: Got first video chunk after %lld ticks.",
+                 nullDuration));
+      // Adapt to the time before the first frame. This extends the first frame
+      // from [start, end] to [0, end], but it'll do for now.
+      mLastChunk.mTimeStamp -=
+        TimeDuration::FromMicroseconds(
+          RateConvertTicksRoundUp(PR_USEC_PER_SEC, mTrackRate, nullDuration));
+    }
 
-      // Because we may get chunks with a null image (due to input blocking),
-      // accumulate duration and give it to the next frame that arrives.
-      // Canonically incorrect - the duration should go to the previous frame
-      // - but that would require delaying until the next frame arrives.
-      // Best would be to do like OMXEncoder and pass an effective timestamp
-      // in with each frame.
-      if (image) {
-        mRawSegment.AppendFrame(image.forget(),
-                                mLastFrameDuration,
-                                chunk.mFrame.GetIntrinsicSize(),
-                                PRINCIPAL_HANDLE_NONE,
-                                chunk.mFrame.GetForceBlack());
-        mLastFrameDuration = 0;
+    MOZ_ASSERT(!mLastChunk.IsNull());
+    if (mLastChunk.CanCombineWithFollowing(chunk) || chunk.IsNull()) {
+      TRACK_LOG(LogLevel::Verbose,
+                ("[VideoTrackEncoder]: Got dupe or null chunk."));
+      // This is the same frame as before (or null). We extend the last chunk
+      // with its duration.
+      mLastChunk.mDuration += chunk.mDuration;
+
+      if (mLastChunk.mDuration < mTrackRate) {
+        TRACK_LOG(LogLevel::Verbose,
+                  ("[VideoTrackEncoder]: Ignoring dupe/null chunk of duration "
+                   "%lld", chunk.mDuration));
+        continue;
+      }
+
+      TRACK_LOG(LogLevel::Verbose,
+                ("[VideoTrackEncoder]: Chunk >1 second. duration=%lld, "
+                 "trackRate=%lld", mLastChunk.mDuration, mTrackRate));
+
+      // If we have gotten dupes for over a second, we force send one
+      // to the encoder to make sure there is some output.
+      chunk.mTimeStamp = mLastChunk.mTimeStamp + TimeDuration::FromSeconds(1);
+
+      if (chunk.IsNull()) {
+        // Ensure that we don't pass null to the encoder by making mLastChunk
+        // null later on.
+        chunk.mFrame = mLastChunk.mFrame;
       }
     }
-    mLastFrame.TakeFrom(&chunk.mFrame);
-    iter.Next();
+
+    TimeDuration diff = chunk.mTimeStamp - mLastChunk.mTimeStamp;
+    if (diff <= TimeDuration::FromSeconds(0)) {
+      // The timestamp from mLastChunk is newer than from chunk.
+      // This means the durations reported from MediaStreamGraph for mLastChunk
+      // were larger than the timestamp diff - and durations were used to
+      // trigger the 1-second frame above. This could happen due to drift or
+      // underruns in the graph.
+      TRACK_LOG(LogLevel::Warning,
+                ("[VideoTrackEncoder]: Underrun detected. Diff=%.5fs",
+                 diff.ToSeconds()));
+      chunk.mTimeStamp = mLastChunk.mTimeStamp;
+    } else {
+      RefPtr<layers::Image> lastImage = mLastChunk.mFrame.GetImage();
+      TRACK_LOG(LogLevel::Verbose,
+                ("[VideoTrackEncoder]: Appending video frame %p, duration=%.5f",
+                 lastImage.get(), diff.ToSeconds()));
+      mRawSegment.AppendFrame(lastImage.forget(),
+                              RateConvertTicksRoundUp(
+                                  mTrackRate, PR_USEC_PER_SEC,
+                                  diff.ToMicroseconds()),
+                              mLastChunk.mFrame.GetIntrinsicSize(),
+                              PRINCIPAL_HANDLE_NONE,
+                              mLastChunk.mFrame.GetForceBlack(),
+                              mLastChunk.mTimeStamp);
+    }
+
+    mLastChunk = chunk;
   }
 
   if (mRawSegment.GetDuration() > 0) {
     mReentrantMonitor.NotifyAll();
   }
 
   return NS_OK;
 }
--- a/dom/media/encoder/TrackEncoder.h
+++ b/dom/media/encoder/TrackEncoder.h
@@ -250,20 +250,20 @@ class VideoTrackEncoder : public TrackEn
 public:
   explicit VideoTrackEncoder(TrackRate aTrackRate)
     : TrackEncoder()
     , mFrameWidth(0)
     , mFrameHeight(0)
     , mDisplayWidth(0)
     , mDisplayHeight(0)
     , mTrackRate(aTrackRate)
-    , mTotalFrameDuration(0)
-    , mLastFrameDuration(0)
     , mVideoBitrate(0)
-  {}
+  {
+    mLastChunk.mDuration = 0;
+  }
 
   /**
    * Notified by the same callback of MediaEncoder when it has received a track
    * change from MediaStreamGraph. Called on the MediaStreamGraph thread.
    */
   void NotifyQueuedTrackChanges(MediaStreamGraph* aGraph, TrackID aID,
                                 StreamTime aTrackOffset,
                                 uint32_t aTrackEvents,
@@ -334,27 +334,20 @@ protected:
   int mDisplayHeight;
 
   /**
    * The track rate of source video.
    */
   TrackRate mTrackRate;
 
   /**
-   * The total duration of frames in encoded video in StreamTime, kept track of
-   * in subclasses.
-   */
-  StreamTime mTotalFrameDuration;
-
-  /**
    * The last unique frame and duration we've sent to track encoder,
    * kept track of in subclasses.
    */
-  VideoFrame mLastFrame;
-  StreamTime mLastFrameDuration;
+  VideoChunk mLastChunk;
 
   /**
    * A segment queue of audio track data, protected by mReentrantMonitor.
    */
   VideoSegment mRawSegment;
 
   uint32_t mVideoBitrate;
 };
--- a/dom/media/gmp-plugin-openh264/gmp-fake-openh264.cpp
+++ b/dom/media/gmp-plugin-openh264/gmp-fake-openh264.cpp
@@ -405,17 +405,17 @@ extern "C" {
     if (!strcmp (aApiName, GMP_API_VIDEO_DECODER)) {
       *aPluginApi = new FakeVideoDecoder (static_cast<GMPVideoHost*> (aHostAPI));
       return GMPNoErr;
     } else if (!strcmp (aApiName, GMP_API_VIDEO_ENCODER)) {
       *aPluginApi = new FakeVideoEncoder (static_cast<GMPVideoHost*> (aHostAPI));
       return GMPNoErr;
 #if defined(GMP_FAKE_SUPPORT_DECRYPT)
     } else if (!strcmp (aApiName, GMP_API_DECRYPTOR)) {
-      *aPluginApi = new FakeDecryptor(static_cast<GMPDecryptorHost*> (aHostAPI));
+      *aPluginApi = new FakeDecryptor();
       return GMPNoErr;
 #endif
     }
     return GMPGenericErr;
   }
 
   PUBLIC_FUNC void
   GMPShutdown (void) {
--- a/dom/media/gmp-plugin/gmp-fake.cpp
+++ b/dom/media/gmp-plugin/gmp-fake.cpp
@@ -68,17 +68,17 @@ extern "C" {
   GMPGetAPI (const char* aApiName, void* aHostAPI, void** aPluginApi) {
     if (!strcmp (aApiName, GMP_API_VIDEO_DECODER)) {
       // Note: Deliberately advertise in our .info file that we support
       // video-decode, but we fail the "get" call here to simulate what
       // happens when decoder init fails.
       return GMPGenericErr;
 #if defined(GMP_FAKE_SUPPORT_DECRYPT)
     } else if (!strcmp (aApiName, GMP_API_DECRYPTOR)) {
-      *aPluginApi = new FakeDecryptor(static_cast<GMPDecryptorHost*> (aHostAPI));
+      *aPluginApi = new FakeDecryptor();
       return GMPNoErr;
 #endif
     }
     return GMPGenericErr;
   }
 
   PUBLIC_FUNC void
   GMPShutdown (void) {
--- a/dom/media/gmp-plugin/gmp-test-decryptor.cpp
+++ b/dom/media/gmp-plugin/gmp-test-decryptor.cpp
@@ -93,19 +93,18 @@ private:
     g_platform_api->createmutex(&mutex);
     return mutex;
   }
 
   GMPMutex* const mMutex;
   set<string> mTestIDs;
 };
 
-FakeDecryptor::FakeDecryptor(GMPDecryptorHost* aHost)
+FakeDecryptor::FakeDecryptor()
   : mCallback(nullptr)
-  , mHost(aHost)
 {
   MOZ_ASSERT(!sInstance);
   sInstance = this;
 }
 
 void FakeDecryptor::DecryptingComplete()
 {
   sInstance = nullptr;
@@ -556,20 +555,14 @@ FakeDecryptor::UpdateSession(uint32_t aP
       sShutdownMode = ShutdownStoreToken;
       sShutdownToken = tokens[2];
       Message("shutdown-token received " + sShutdownToken);
     }
   } else if (task == "retrieve-shutdown-token") {
     ReadRecord("shutdown-token", new ReportReadRecordContinuation("shutdown-token"));
   } else if (task == "test-op-apis") {
     mozilla::gmptest::TestOuputProtectionAPIs();
-  } else if (task == "retrieve-plugin-voucher") {
-    const uint8_t* rawVoucher = nullptr;
-    uint32_t length = 0;
-    mHost->GetPluginVoucher(&rawVoucher, &length);
-    std::string voucher((const char*)rawVoucher, (const char*)(rawVoucher + length));
-    Message("retrieved plugin-voucher: " + voucher);
   } else if (task == "retrieve-record-names") {
     GMPEnumRecordNames(&RecvGMPRecordIterator, this);
   } else if (task == "retrieve-node-id") {
     Message("node-id " + sNodeId);
   }
 }
--- a/dom/media/gmp-plugin/gmp-test-decryptor.h
+++ b/dom/media/gmp-plugin/gmp-test-decryptor.h
@@ -8,17 +8,17 @@
 
 #include "gmp-decryption.h"
 #include <string>
 #include "mozilla/Attributes.h"
 
 class FakeDecryptor : public GMPDecryptor {
 public:
 
-  explicit FakeDecryptor(GMPDecryptorHost* aHost);
+  explicit FakeDecryptor();
 
   void Init(GMPDecryptorCallback* aCallback,
             bool aDistinctiveIdentifierRequired,
             bool aPersistentStateRequired) override
   {
     mCallback = aCallback;
   }
 
@@ -82,12 +82,11 @@ private:
 
   virtual ~FakeDecryptor() {}
   static FakeDecryptor* sInstance;
   static std::string sNodeId;
 
   void TestStorage();
 
   GMPDecryptorCallback* mCallback;
-  GMPDecryptorHost* mHost;
 };
 
 #endif
--- a/dom/media/gmp/GMPChild.cpp
+++ b/dom/media/gmp/GMPChild.cpp
@@ -22,18 +22,16 @@
 #include "GMPUtils.h"
 #include "prio.h"
 #include "base/task.h"
 #include "widevine-adapter/WidevineAdapter.h"
 
 using namespace mozilla::ipc;
 using mozilla::dom::CrashReporterChild;
 
-static const int MAX_VOUCHER_LENGTH = 500000;
-
 #ifdef XP_WIN
 #include <stdlib.h> // for _exit()
 #else
 #include <unistd.h> // for _exit()
 #endif
 
 #if defined(MOZ_GMP_SANDBOX)
 #if defined(XP_MACOSX)
@@ -95,25 +93,16 @@ GetFileBase(const nsAString& aPluginPath
 
   aBaseName = Substring(parentLeafName,
                         4,
                         parentLeafName.Length() - 1);
   return true;
 }
 
 static bool
-GetFileBase(const nsAString& aPluginPath,
-            nsCOMPtr<nsIFile>& aFileBase,
-            nsAutoString& aBaseName)
-{
-  nsCOMPtr<nsIFile> unusedLibDir;
-  return GetFileBase(aPluginPath, unusedLibDir, aFileBase, aBaseName);
-}
-
-static bool
 GetPluginFile(const nsAString& aPluginPath,
               nsCOMPtr<nsIFile>& aLibDirectory,
               nsCOMPtr<nsIFile>& aLibFile)
 {
   nsAutoString baseName;
   GetFileBase(aPluginPath, aLibDirectory, aLibFile, baseName);
 
 #if defined(XP_MACOSX)
@@ -240,33 +229,31 @@ GMPChild::SetMacSandboxInfo(MacSandboxPl
 
   mGMPLoader->SetSandboxInfo(&info);
   return true;
 }
 #endif // XP_MACOSX && MOZ_GMP_SANDBOX
 
 bool
 GMPChild::Init(const nsAString& aPluginPath,
-               const nsAString& aVoucherPath,
                base::ProcessId aParentPid,
                MessageLoop* aIOLoop,
                IPC::Channel* aChannel)
 {
   LOGD("%s pluginPath=%s", __FUNCTION__, NS_ConvertUTF16toUTF8(aPluginPath).get());
 
   if (NS_WARN_IF(!Open(aChannel, aParentPid, aIOLoop))) {
     return false;
   }
 
 #ifdef MOZ_CRASHREPORTER
   SendPCrashReporterConstructor(CrashReporter::CurrentThreadId());
 #endif
 
   mPluginPath = aPluginPath;
-  mSandboxVoucherPath = aVoucherPath;
 
   return true;
 }
 
 mozilla::ipc::IPCResult
 GMPChild::RecvSetNodeId(const nsCString& aNodeId)
 {
   LOGD("%s nodeId=%s", __FUNCTION__, aNodeId.Data());
@@ -348,22 +335,16 @@ GMPChild::GetUTF8LibPath(nsACString& aOu
 #endif
 }
 
 mozilla::ipc::IPCResult
 GMPChild::AnswerStartPlugin(const nsString& aAdapter)
 {
   LOGD("%s", __FUNCTION__);
 
-  if (!PreLoadPluginVoucher()) {
-    NS_WARNING("Plugin voucher failed to load!");
-    return IPC_FAIL_NO_REASON(this);
-  }
-  PreLoadSandboxVoucher();
-
   nsCString libPath;
   if (!GetUTF8LibPath(libPath)) {
     return IPC_FAIL_NO_REASON(this);
   }
 
   auto platformAPI = new GMPPlatformAPI();
   InitPlatformAPI(*platformAPI, this);
 
@@ -529,57 +510,16 @@ mozilla::ipc::IPCResult
 GMPChild::RecvCloseActive()
 {
   for (uint32_t i = mGMPContentChildren.Length(); i > 0; i--) {
     mGMPContentChildren[i - 1]->CloseActive();
   }
   return IPC_OK();
 }
 
-static void
-GetPluginVoucherFile(const nsAString& aPluginPath,
-                     nsCOMPtr<nsIFile>& aOutVoucherFile)
-{
-  nsAutoString baseName;
-  GetFileBase(aPluginPath, aOutVoucherFile, baseName);
-  nsAutoString infoFileName = baseName + NS_LITERAL_STRING(".voucher");
-  aOutVoucherFile->AppendRelativePath(infoFileName);
-}
-
-bool
-GMPChild::PreLoadPluginVoucher()
-{
-  nsCOMPtr<nsIFile> voucherFile;
-  GetPluginVoucherFile(mPluginPath, voucherFile);
-  if (!FileExists(voucherFile)) {
-    // Assume missing file is not fatal; that would break OpenH264.
-    return true;
-  }
-  return ReadIntoArray(voucherFile, mPluginVoucher, MAX_VOUCHER_LENGTH);
-}
-
-void
-GMPChild::PreLoadSandboxVoucher()
-{
-  nsCOMPtr<nsIFile> f;
-  nsresult rv = NS_NewLocalFile(mSandboxVoucherPath, true, getter_AddRefs(f));
-  if (NS_FAILED(rv)) {
-    NS_WARNING("Can't create nsIFile for sandbox voucher");
-    return;
-  }
-  if (!FileExists(f)) {
-    // Assume missing file is not fatal; that would break OpenH264.
-    return;
-  }
-
-  if (!ReadIntoArray(f, mSandboxVoucher, MAX_VOUCHER_LENGTH)) {
-    NS_WARNING("Failed to read sandbox voucher");
-  }
-}
-
 PGMPContentChild*
 GMPChild::AllocPGMPContentChild(Transport* aTransport,
                                 ProcessId aOtherPid)
 {
   GMPContentChild* child =
     mGMPContentChildren.AppendElement(new GMPContentChild(this))->get();
   child->Open(aTransport, aOtherPid, XRE_GetIOMessageLoop(), ipc::ChildSide);
 
--- a/dom/media/gmp/GMPChild.h
+++ b/dom/media/gmp/GMPChild.h
@@ -20,36 +20,32 @@ class GMPContentChild;
 
 class GMPChild : public PGMPChild
 {
 public:
   GMPChild();
   virtual ~GMPChild();
 
   bool Init(const nsAString& aPluginPath,
-            const nsAString& aVoucherPath,
             base::ProcessId aParentPid,
             MessageLoop* aIOLoop,
             IPC::Channel* aChannel);
   MessageLoop* GMPMessageLoop();
 
   // Main thread only.
   GMPTimerChild* GetGMPTimers();
   GMPStorageChild* GetGMPStorage();
 
 #if defined(XP_MACOSX) && defined(MOZ_GMP_SANDBOX)
   bool SetMacSandboxInfo(MacSandboxPluginType aPluginType);
 #endif
 
 private:
   friend class GMPContentChild;
 
-  bool PreLoadPluginVoucher();
-  void PreLoadSandboxVoucher();
-
   bool GetUTF8LibPath(nsACString& aOutLibPath);
 
   mozilla::ipc::IPCResult RecvSetNodeId(const nsCString& aNodeId) override;
   mozilla::ipc::IPCResult AnswerStartPlugin(const nsString& aAdapter) override;
   mozilla::ipc::IPCResult RecvPreloadLibs(const nsCString& aLibs) override;
 
   PCrashReporterChild* AllocPCrashReporterChild(const NativeThreadId& aThread) override;
   bool DeallocPCrashReporterChild(PCrashReporterChild*) override;
@@ -74,19 +70,16 @@ private:
 
   nsTArray<UniquePtr<GMPContentChild>> mGMPContentChildren;
 
   RefPtr<GMPTimerChild> mTimerChild;
   RefPtr<GMPStorageChild> mStorage;
 
   MessageLoop* mGMPMessageLoop;
   nsString mPluginPath;
-  nsString mSandboxVoucherPath;
   nsCString mNodeId;
   GMPLoader* mGMPLoader;
-  nsTArray<uint8_t> mPluginVoucher;
-  nsTArray<uint8_t> mSandboxVoucher;
 };
 
 } // namespace gmp
 } // namespace mozilla
 
 #endif // GMPChild_h_
--- a/dom/media/gmp/GMPContentChild.cpp
+++ b/dom/media/gmp/GMPContentChild.cpp
@@ -46,19 +46,17 @@ void
 GMPContentChild::ProcessingError(Result aCode, const char* aReason)
 {
   mGMPChild->ProcessingError(aCode, aReason);
 }
 
 PGMPDecryptorChild*
 GMPContentChild::AllocPGMPDecryptorChild()
 {
-  GMPDecryptorChild* actor = new GMPDecryptorChild(this,
-                                                   mGMPChild->mPluginVoucher,
-                                                   mGMPChild->mSandboxVoucher);
+  GMPDecryptorChild* actor = new GMPDecryptorChild(this);
   actor->AddRef();
   return actor;
 }
 
 bool
 GMPContentChild::DeallocPGMPDecryptorChild(PGMPDecryptorChild* aActor)
 {
   static_cast<GMPDecryptorChild*>(aActor)->Release();
@@ -94,20 +92,19 @@ GMPContentChild::DeallocPGMPVideoEncoder
   static_cast<GMPVideoEncoderChild*>(aActor)->Release();
   return true;
 }
 
 mozilla::ipc::IPCResult
 GMPContentChild::RecvPGMPDecryptorConstructor(PGMPDecryptorChild* aActor)
 {
   GMPDecryptorChild* child = static_cast<GMPDecryptorChild*>(aActor);
-  GMPDecryptorHost* host = static_cast<GMPDecryptorHost*>(child);
 
   void* ptr = nullptr;
-  GMPErr err = mGMPChild->GetAPI(GMP_API_DECRYPTOR, host, &ptr, aActor->Id());
+  GMPErr err = mGMPChild->GetAPI(GMP_API_DECRYPTOR, nullptr, &ptr, aActor->Id());
   if (err != GMPNoErr || !ptr) {
     NS_WARNING("GMPGetAPI call failed trying to construct decryptor.");
     return IPC_FAIL_NO_REASON(this);
   }
   child->Init(static_cast<GMPDecryptor*>(ptr));
 
   return IPC_OK();
 }
--- a/dom/media/gmp/GMPDecryptorChild.cpp
+++ b/dom/media/gmp/GMPDecryptorChild.cpp
@@ -15,23 +15,19 @@
 #define ON_GMP_THREAD() (mPlugin->GMPMessageLoop() == MessageLoop::current())
 
 #define CALL_ON_GMP_THREAD(_func, ...) \
   CallOnGMPThread(&GMPDecryptorChild::_func, __VA_ARGS__)
 
 namespace mozilla {
 namespace gmp {
 
-GMPDecryptorChild::GMPDecryptorChild(GMPContentChild* aPlugin,
-                                     const nsTArray<uint8_t>& aPluginVoucher,
-                                     const nsTArray<uint8_t>& aSandboxVoucher)
+GMPDecryptorChild::GMPDecryptorChild(GMPContentChild* aPlugin)
   : mSession(nullptr)
   , mPlugin(aPlugin)
-  , mPluginVoucher(aPluginVoucher)
-  , mSandboxVoucher(aSandboxVoucher)
 {
   MOZ_ASSERT(mPlugin);
 }
 
 GMPDecryptorChild::~GMPDecryptorChild()
 {
 }
 
@@ -218,38 +214,16 @@ GMPDecryptorChild::Decrypted(GMPBuffer* 
 }
 
 void
 GMPDecryptorChild::SetCapabilities(uint64_t aCaps)
 {
   // Deprecated.
 }
 
-void
-GMPDecryptorChild::GetSandboxVoucher(const uint8_t** aVoucher,
-                                     uint32_t* aVoucherLength)
-{
-  if (!aVoucher || !aVoucherLength) {
-    return;
-  }
-  *aVoucher = mSandboxVoucher.Elements();
-  *aVoucherLength = mSandboxVoucher.Length();
-}
-
-void
-GMPDecryptorChild::GetPluginVoucher(const uint8_t** aVoucher,
-                                    uint32_t* aVoucherLength)
-{
-  if (!aVoucher || !aVoucherLength) {
-    return;
-  }
-  *aVoucher = mPluginVoucher.Elements();
-  *aVoucherLength = mPluginVoucher.Length();
-}
-
 mozilla::ipc::IPCResult
 GMPDecryptorChild::RecvInit(const bool& aDistinctiveIdentifierRequired,
                             const bool& aPersistentStateRequired)
 {
   if (!mSession) {
     return IPC_FAIL_NO_REASON(this);
   }
   mSession->Init(this, aDistinctiveIdentifierRequired, aPersistentStateRequired);
--- a/dom/media/gmp/GMPDecryptorChild.h
+++ b/dom/media/gmp/GMPDecryptorChild.h
@@ -13,25 +13,22 @@
 #include <string>
 
 namespace mozilla {
 namespace gmp {
 
 class GMPContentChild;
 
 class GMPDecryptorChild : public GMPDecryptorCallback
-                        , public GMPDecryptorHost
                         , public PGMPDecryptorChild
 {
 public:
   NS_INLINE_DECL_THREADSAFE_REFCOUNTING(GMPDecryptorChild);
 
-  explicit GMPDecryptorChild(GMPContentChild* aPlugin,
-                             const nsTArray<uint8_t>& aPluginVoucher,
-                             const nsTArray<uint8_t>& aSandboxVoucher);
+  explicit GMPDecryptorChild(GMPContentChild* aPlugin);
 
   void Init(GMPDecryptor* aSession);
 
   // GMPDecryptorCallback
   void SetSessionId(uint32_t aCreateSessionToken,
                     const char* aSessionId,
                     uint32_t aSessionIdLength) override;
   void ResolveLoadSessionPromise(uint32_t aPromiseId,
@@ -73,22 +70,16 @@ public:
 
   void Decrypted(GMPBuffer* aBuffer, GMPErr aResult) override;
 
   void BatchedKeyStatusChanged(const char* aSessionId,
                                uint32_t aSessionIdLength,
                                const GMPMediaKeyInfo* aKeyInfos,
                                uint32_t aKeyInfosLength) override;
 
-  // GMPDecryptorHost
-  void GetSandboxVoucher(const uint8_t** aVoucher,
-                         uint32_t* aVoucherLength) override;
-
-  void GetPluginVoucher(const uint8_t** aVoucher,
-                        uint32_t* aVoucherLength) override;
 private:
   ~GMPDecryptorChild();
 
   // GMPDecryptorChild
   mozilla::ipc::IPCResult RecvInit(const bool& aDistinctiveIdentifierRequired,
                                    const bool& aPersistentStateRequired) override;
 
   mozilla::ipc::IPCResult RecvCreateSession(const uint32_t& aCreateSessionToken,
@@ -125,18 +116,14 @@ private:
 
   template<typename MethodType, typename... ParamType>
   void CallOnGMPThread(MethodType, ParamType&&...);
 
   // GMP's GMPDecryptor implementation.
   // Only call into this on the (GMP process) main thread.
   GMPDecryptor* mSession;
   GMPContentChild* mPlugin;
-
-  // Reference to the vouchers owned by the GMPChild.
-  const nsTArray<uint8_t>& mPluginVoucher;
-  const nsTArray<uint8_t>& mSandboxVoucher;
 };
 
 } // namespace gmp
 } // namespace mozilla
 
 #endif // GMPDecryptorChild_h_
--- a/dom/media/gmp/GMPProcessChild.cpp
+++ b/dom/media/gmp/GMPProcessChild.cpp
@@ -23,39 +23,35 @@ GMPProcessChild::GMPProcessChild(Process
 GMPProcessChild::~GMPProcessChild()
 {
 }
 
 bool
 GMPProcessChild::Init()
 {
   nsAutoString pluginFilename;
-  nsAutoString voucherFilename;
 
 #if defined(OS_POSIX)
   // NB: need to be very careful in ensuring that the first arg
   // (after the binary name) here is indeed the plugin module path.
   // Keep in sync with dom/plugins/PluginModuleParent.
   std::vector<std::string> values = CommandLine::ForCurrentProcess()->argv();
-  MOZ_ASSERT(values.size() >= 3, "not enough args");
+  MOZ_ASSERT(values.size() >= 2, "not enough args");
   pluginFilename = NS_ConvertUTF8toUTF16(nsDependentCString(values[1].c_str()));
-  voucherFilename = NS_ConvertUTF8toUTF16(nsDependentCString(values[2].c_str()));
 #elif defined(OS_WIN)
   std::vector<std::wstring> values = CommandLine::ForCurrentProcess()->GetLooseValues();
-  MOZ_ASSERT(values.size() >= 2, "not enough loose args");
+  MOZ_ASSERT(values.size() >= 1, "not enough loose args");
   pluginFilename = nsDependentString(values[0].c_str());
-  voucherFilename = nsDependentString(values[1].c_str());
 #else
 #error Not implemented
 #endif
 
   BackgroundHangMonitor::Startup();
 
   return mPlugin.Init(pluginFilename,
-                      voucherFilename,
                       ParentPid(),
                       IOThreadChild::message_loop(),
                       IOThreadChild::channel());
 }
 
 void
 GMPProcessChild::CleanUp()
 {
--- a/dom/media/gmp/GMPProcessParent.cpp
+++ b/dom/media/gmp/GMPProcessParent.cpp
@@ -37,24 +37,16 @@ GMPProcessParent::GMPProcessParent(const
 GMPProcessParent::~GMPProcessParent()
 {
   MOZ_COUNT_DTOR(GMPProcessParent);
 }
 
 bool
 GMPProcessParent::Launch(int32_t aTimeoutMs)
 {
-  nsCOMPtr<nsIFile> path;
-  if (!GetEMEVoucherPath(getter_AddRefs(path))) {
-    NS_WARNING("GMPProcessParent can't get EME voucher path!");
-    return false;
-  }
-  nsAutoCString voucherPath;
-  path->GetNativePath(voucherPath);
-
   vector<string> args;
 
 #if defined(XP_WIN) && defined(MOZ_SANDBOX)
   std::wstring wGMPPath = UTF8ToWide(mGMPPath.c_str());
 
   // The sandbox doesn't allow file system rules where the paths contain
   // symbolic links or junction points. Sometimes the Users folder has been
   // moved to another drive using a junction point, so allow for this specific
@@ -77,18 +69,16 @@ GMPProcessParent::Launch(int32_t aTimeou
     mAllowedFilesRead.push_back(wGMPPath + L"\\*");
   }
 
   args.push_back(WideToUTF8(wGMPPath));
 #else
   args.push_back(mGMPPath);
 #endif
 
-  args.push_back(string(voucherPath.BeginReading(), voucherPath.EndReading()));
-
   return SyncLaunch(args, aTimeoutMs, base::GetCurrentProcessArchitecture());
 }
 
 void
 GMPProcessParent::Delete(nsCOMPtr<nsIRunnable> aCallback)
 {
   mDeletedCallback = aCallback;
   XRE_GetIOMessageLoop()->PostTask(NewNonOwningRunnableMethod(this, &GMPProcessParent::DoDelete));
--- a/dom/media/gmp/GMPUtils.cpp
+++ b/dom/media/gmp/GMPUtils.cpp
@@ -10,40 +10,16 @@
 #include "nsCOMPtr.h"
 #include "nsLiteralString.h"
 #include "nsCRTGlue.h"
 #include "mozilla/Base64.h"
 #include "nsISimpleEnumerator.h"
 
 namespace mozilla {
 
-bool
-GetEMEVoucherPath(nsIFile** aPath)
-{
-  nsCOMPtr<nsIFile> path;
-  NS_GetSpecialDirectory(NS_GRE_DIR, getter_AddRefs(path));
-  if (!path) {
-    NS_WARNING("GetEMEVoucherPath can't get NS_GRE_DIR!");
-    return false;
-  }
-  path->AppendNative(NS_LITERAL_CSTRING("voucher.bin"));
-  path.forget(aPath);
-  return true;
-}
-
-bool
-EMEVoucherFileExists()
-{
-  nsCOMPtr<nsIFile> path;
-  bool exists;
-  return GetEMEVoucherPath(getter_AddRefs(path)) &&
-         NS_SUCCEEDED(path->Exists(&exists)) &&
-         exists;
-}
-
 void
 SplitAt(const char* aDelims,
         const nsACString& aInput,
         nsTArray<nsCString>& aOutTokens)
 {
   nsAutoCString str(aInput);
   char* end = str.BeginWriting();
   const char* start = nullptr;
--- a/dom/media/gmp/GMPUtils.h
+++ b/dom/media/gmp/GMPUtils.h
@@ -23,20 +23,16 @@ struct DestroyPolicy
   void operator()(T* aGMPObject) const {
     aGMPObject->Destroy();
   }
 };
 
 template<typename T>
 using GMPUniquePtr = mozilla::UniquePtr<T, DestroyPolicy<T>>;
 
-bool GetEMEVoucherPath(nsIFile** aPath);
-
-bool EMEVoucherFileExists();
-
 void
 SplitAt(const char* aDelims,
         const nsACString& aInput,
         nsTArray<nsCString>& aOutTokens);
 
 nsCString
 ToHexString(const nsTArray<uint8_t>& aBytes);
 
@@ -69,21 +65,16 @@ public:
   bool Init(nsIFile* aFile);
   bool Contains(const nsCString& aKey) const;
   nsCString Get(const nsCString& aKey) const;
 private:
   nsClassHashtable<nsCStringHashKey, nsCString> mValues;
 };
 
 bool
-ReadIntoArray(nsIFile* aFile,
-              nsTArray<uint8_t>& aOutDst,
-              size_t aMaxLength);
-
-bool
 ReadIntoString(nsIFile* aFile,
                nsCString& aOutDst,
                size_t aMaxLength);
 
 bool
 HaveGMPFor(const nsCString& aAPI,
            nsTArray<nsCString>&& aTags);
 
--- a/dom/media/gmp/gmp-api/gmp-decryption.h
+++ b/dom/media/gmp/gmp-api/gmp-decryption.h
@@ -211,41 +211,28 @@ public:
   virtual void BatchedKeyStatusChanged(const char* aSessionId,
                                        uint32_t aSessionIdLength,
                                        const GMPMediaKeyInfo* aKeyInfos,
                                        uint32_t aKeyInfosLength) = 0;
 
   virtual ~GMPDecryptorCallback() {}
 };
 
-// Host interface, passed to GetAPIFunc(), with "decrypt".
-class GMPDecryptorHost {
-public:
-  virtual void GetSandboxVoucher(const uint8_t** aVoucher,
-                                 uint32_t* aVoucherLength) = 0;
-
-  virtual void GetPluginVoucher(const uint8_t** aVoucher,
-                                uint32_t* aVoucherLength) = 0;
-
-  virtual ~GMPDecryptorHost() {}
-};
-
 enum GMPSessionType {
   kGMPTemporySession = 0,
   kGMPPersistentSession = 1,
   kGMPSessionInvalid = 2 // Must always be last.
 };
 
 #define GMP_API_DECRYPTOR "eme-decrypt-v9"
 
 // API exposed by plugin library to manage decryption sessions.
 // When the Host requests this by calling GMPGetAPIFunc().
 //
 // API name macro: GMP_API_DECRYPTOR
-// Host API: GMPDecryptorHost
 class GMPDecryptor {
 public:
 
   // Sets the callback to use with the decryptor to return results
   // to Gecko.
   virtual void Init(GMPDecryptorCallback* aCallback,
                     bool aDistinctiveIdentifierRequired,
                     bool aPersistentStateRequired) = 0;
--- a/dom/media/gtest/TestGMPCrossOrigin.cpp
+++ b/dom/media/gtest/TestGMPCrossOrigin.cpp
@@ -1104,26 +1104,16 @@ class GMPStorageTest : public GMPDecrypt
 
     CreateDecryptor(NS_LITERAL_STRING("http://example15.com"),
                     NS_LITERAL_STRING("http://example16.com"),
                     false,
                     NS_LITERAL_CSTRING("test-op-apis"));
   }
 #endif
 
-  void TestPluginVoucher() {
-    Expect(NS_LITERAL_CSTRING("retrieved plugin-voucher: gmp-fake placeholder voucher"),
-           NewRunnableMethod(this, &GMPStorageTest::SetFinished));
-
-    CreateDecryptor(NS_LITERAL_STRING("http://example17.com"),
-                    NS_LITERAL_STRING("http://example18.com"),
-                    false,
-                    NS_LITERAL_CSTRING("retrieve-plugin-voucher"));
-  }
-
   void TestGetRecordNamesInMemoryStorage() {
     TestGetRecordNames(true);
   }
 
   nsCString mRecordNames;
 
   void AppendIntPadded(nsACString& aString, uint32_t aInt) {
     if (aInt > 0 && aInt < 10) {
@@ -1423,21 +1413,16 @@ TEST(GeckoMediaPlugins, GMPStorageCrossO
   runner->DoTest(&GMPStorageTest::TestCrossOriginStorage);
 }
 
 TEST(GeckoMediaPlugins, GMPStoragePrivateBrowsing) {
   RefPtr<GMPStorageTest> runner = new GMPStorageTest();
   runner->DoTest(&GMPStorageTest::TestPBStorage);
 }
 
-TEST(GeckoMediaPlugins, GMPPluginVoucher) {
-  RefPtr<GMPStorageTest> runner = new GMPStorageTest();
-  runner->DoTest(&GMPStorageTest::TestPluginVoucher);
-}
-
 #if defined(XP_WIN)
 TEST(GeckoMediaPlugins, GMPOutputProtection) {
   RefPtr<GMPStorageTest> runner = new GMPStorageTest();
   runner->DoTest(&GMPStorageTest::TestOutputProtection);
 }
 #endif
 
 TEST(GeckoMediaPlugins, GMPStorageGetRecordNamesInMemoryStorage) {
--- a/dom/media/gtest/TestVideoTrackEncoder.cpp
+++ b/dom/media/gtest/TestVideoTrackEncoder.cpp
@@ -260,23 +260,26 @@ TEST(VP8VideoTrackEncoder, FrameEncode)
   nsTArray<RefPtr<Image>> images;
   YUVBufferGenerator generator;
   generator.Init(mozilla::gfx::IntSize(640, 480));
   generator.Generate(images);
 
   // Put generated YUV frame into video segment.
   // Duration of each frame is 1 second.
   VideoSegment segment;
+  TimeStamp now = TimeStamp::Now();
   for (nsTArray<RefPtr<Image>>::size_type i = 0; i < images.Length(); i++)
   {
     RefPtr<Image> image = images[i];
     segment.AppendFrame(image.forget(),
                         mozilla::StreamTime(90000),
                         generator.GetSize(),
-                        PRINCIPAL_HANDLE_NONE);
+                        PRINCIPAL_HANDLE_NONE,
+                        false,
+                        now + TimeDuration::FromSeconds(i));
   }
 
   // track change notification.
   encoder.SetCurrentFrames(segment);
 
   // Pull Encoded Data back from encoder.
   EncodedFrameContainer container;
   EXPECT_TRUE(NS_SUCCEEDED(encoder.GetEncodedTrack(container)));
new file mode 100644
--- /dev/null
+++ b/dom/media/test/external/external_media_tests/media_utils/twitch_puppeteer.py
@@ -0,0 +1,192 @@
+# 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/.
+from collections import namedtuple
+from time import clock
+
+from marionette_driver import By, expected, Wait
+from marionette_harness import Marionette
+
+from video_puppeteer import VideoPuppeteer, TimeRanges
+from external_media_tests.utils import verbose_until
+
+
+class TwitchPuppeteer(VideoPuppeteer):
+    """
+    Wrapper around a Twitch .player element.
+
+    Note that the twitch stream needs to be playing for the puppeteer to work
+    correctly. Since twitch will load a player element for streams that are
+    not currently playing, the puppeteer will still detect a player element,
+    however the time ranges will not increase, and tests will fail.
+
+    Compared to video puppeteer, this class has the benefit of accessing the
+    twitch player element as well as the video element. The twitch player
+    element reports additional information to aid in testing -- such as if an
+    ad is playing. However, the attributes of this element do not appear to be
+    documented, which may leave them vulnerable to undocumented changes in
+    behaviour.
+    """
+    _player_var_script = (
+        'var player_data_screen = '
+        'arguments[1].wrappedJSObject.getAttribute("data-screen");'
+    )
+    """
+    A string containing JS that will assign player state to
+    variables. This is similar to `_video_var_script` from
+    `VideoPuppeteer`. See `_video_var_script` for more information on the
+    motivation for this method.
+    """
+
+    def __init__(self, marionette, url, autostart=True,
+                 set_duration=10.0, **kwargs):
+        self.player = None
+        self._last_seen_player_state = None
+        super(TwitchPuppeteer,
+              self).__init__(marionette, url, set_duration=set_duration,
+                             autostart=False, **kwargs)
+        wait = Wait(self.marionette, timeout=30)
+        with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
+            verbose_until(wait, self,
+                          expected.element_present(By.CLASS_NAME,
+                                                   'player'))
+            self.player = self.marionette.find_element(By.CLASS_NAME,
+                                                       'player')
+            self.marionette.execute_script("log('.player "
+                                           "element obtained');")
+            if autostart:
+                self.start()
+
+    def _update_expected_duration(self):
+        if 0 < self._set_duration:
+            self.expected_duration = self._set_duration
+        else:
+            # If we don't have a set duration we don't know how long is left
+            # in the stream.
+            self.expected_duration = float("inf")
+
+    def _calculate_remaining_time(self, played_ranges):
+        """
+        Override of video_puppeteer's _calculate_remaining_time. See that
+        method for primary documentation.
+
+        This override is in place to adjust how remaining time is handled.
+        Twitch ads can cause small stutters which result in multiple played
+        ranges, despite no seeks being initiated by the tests. As such, when
+        calculating the remaining time, the start time is the min of all
+        played start times, and the end time is the max of played end times.
+        This being sensible behaviour relies on the tests not attempting seeks.
+
+        :param played_ranges: A TimeRanges object containing played ranges.
+        :return: The remaining time expected for this puppeteer.
+        """
+        min_played_time = min(
+            [played_ranges.start(i) for i in range(0, played_ranges.length)])
+        max_played_time = max(
+            [played_ranges.end(i) for i in range(0, played_ranges.length)])
+        played_duration = max_played_time - min_played_time
+        return self.expected_duration - played_duration
+
+    def _execute_twitch_script(self, script):
+        """
+        Execute JS script in content context with access to video element and
+        Twitch .player element.
+
+        :param script: script to be executed.
+
+        :return: value returned by script
+        """
+        with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
+            return self.marionette.execute_script(script,
+                                                  script_args=[self.video,
+                                                               self.player])
+
+    @staticmethod
+    def _twitch_state_named_tuple():
+        """
+        Create a named tuple class that can be used to store state snapshots
+        of the wrapped twitch player. The fields in the tuple should be used
+        as follows:
+
+        player_data_screen: the current displayed content, appears to be set
+        to nothing if no ad has been played, 'advertisement' during ad
+        playback, and 'content' following ad playback.
+        """
+        return namedtuple('player_state_info',
+                          ['player_data_screen'])
+
+    def _create_player_state_info(self, **player_state_info_kwargs):
+        """
+        Create an instance of the state info named tuple. This function
+        expects a dictionary containing the following keys:
+        player_data_screen.
+
+        For more information on the above keys and their values see
+        `_twitch_state_named_tuple`.
+
+        :return: A named tuple 'player_state_info', derived from arguments and
+        state information from the puppeteer.
+        """
+        # Create player snapshot
+        state_info = self._twitch_state_named_tuple()
+        return state_info(**player_state_info_kwargs)
+
+    @property
+    def _fetch_state_script(self):
+        if not self._fetch_state_script_string:
+            self._fetch_state_script_string = (
+                self._video_var_script +
+                self._player_var_script +
+                'return ['
+                'baseURI,'
+                'currentTime,'
+                'duration,'
+                '[buffered.length, bufferedRanges],'
+                '[played.length, playedRanges],'
+                'totalFrames,'
+                'droppedFrames,'
+                'corruptedFrames,'
+                'player_data_screen];')
+        return self._fetch_state_script_string
+
+    def _refresh_state(self):
+        """
+        Refresh the snapshot of the underlying video and player state. We do
+        this all in one so that the state doesn't change in between queries.
+
+        We also store information thouat can be derived from the snapshotted
+        information, such as lag. This is stored in the last seen state to
+        stress that it's based on the snapshot.
+        """
+        values = self._execute_twitch_script(self._fetch_state_script)
+        video_keys = ['base_uri', 'current_time', 'duration',
+                      'raw_buffered_ranges', 'raw_played_ranges',
+                      'total_frames', 'dropped_frames', 'corrupted_frames']
+        player_keys = ['player_data_screen']
+        # Get video state
+        self._last_seen_video_state = (
+            self._create_video_state_info(**dict(
+                zip(video_keys, values[:len(video_keys)]))))
+        # Get player state
+        self._last_seen_player_state = (
+            self._create_player_state_info(**dict(
+                zip(player_keys, values[-len(player_keys):]))))
+
+    def __str__(self):
+        messages = [super(TwitchPuppeteer, self).__str__()]
+        if not self.player:
+            messages += ['\t.player: None']
+            return '\n'.join(messages)
+        if not self._last_seen_player_state:
+            messages += ['\t.player: No last seen state']
+            return '\n'.join(messages)
+        messages += ['.player: {']
+        for field in self._last_seen_player_state._fields:
+            # For compatibility with different test environments we force
+            # ascii
+            field_ascii = (
+                unicode(getattr(self._last_seen_player_state, field)).encode(
+                    'ascii', 'replace'))
+            messages += [('\t{}: {}'.format(field, field_ascii))]
+        messages += '}'
+        return '\n'.join(messages)
--- a/dom/media/test/external/external_media_tests/media_utils/video_puppeteer.py
+++ b/dom/media/test/external/external_media_tests/media_utils/video_puppeteer.py
@@ -241,16 +241,32 @@ class VideoPuppeteer(object):
             start_position = self._first_seen_time
         # In case video starts at t > 0, adjust target time partial playback
         remaining_video = video_duration - start_position
         if 0 < self._set_duration < remaining_video:
             self.expected_duration = self._set_duration
         else:
             self.expected_duration = remaining_video
 
+    def _calculate_remaining_time(self, played_ranges):
+        """
+        Calculate the remaining time expected for this puppeteer. Note that
+        this method accepts a played range rather than reading from the last
+        seen state. This is so when building a new state we are not forced to
+        read from the last one, and can use the played ranges associated with
+        that new state to calculate the remaining time.
+
+        :param played_ranges: A TimeRanges object containing played ranges.
+        For video_puppeteer we expect a single played range, but overrides may
+        expect different behaviour.
+        :return: The remaining time expected for this puppeteer.
+        """
+        played_duration = played_ranges.end(0) - played_ranges.start(0)
+        return self.expected_duration - played_duration
+
     @staticmethod
     def _video_state_named_tuple():
         """
         Create a named tuple class that can be used to store state snapshots
         of the wrapped element. The fields in the tuple should be used as
         follows:
 
         base_uri: the baseURI attribute of the wrapped element.
@@ -317,21 +333,20 @@ class VideoPuppeteer(object):
         # Calculate elapsed times
         elapsed_current_time = (video_state_info_kwargs['current_time'] -
                                 self._first_seen_time)
         elapsed_wall_time = clock() - self._first_seen_wall_time
         # Calculate lag
         video_state_info_kwargs['lag'] = (
             elapsed_wall_time - elapsed_current_time)
         # Calculate remaining time
-        if video_state_info_kwargs['played'].length > 0:
-            played_duration = (video_state_info_kwargs['played'].end(0) -
-                               video_state_info_kwargs['played'].start(0))
+        played_ranages = video_state_info_kwargs['played']
+        if played_ranages.length > 0:
             video_state_info_kwargs['remaining_time'] = (
-                self.expected_duration - played_duration)
+                self._calculate_remaining_time(played_ranages))
         else:
             # No playback has happened yet, remaining time is duration
             video_state_info_kwargs['remaining_time'] = self.expected_duration
         # Fetch non time critical source information
         video_state_info_kwargs['video_src'] = self.video.get_attribute('src')
         # Create video state snapshot
         state_info = self._video_state_named_tuple()
         return state_info(**video_state_info_kwargs)
--- a/dom/media/test/external/external_media_tests/media_utils/youtube_puppeteer.py
+++ b/dom/media/test/external/external_media_tests/media_utils/youtube_puppeteer.py
@@ -303,17 +303,17 @@ class YouTubePuppeteer(VideoPuppeteer):
         expects a dictionary containing the following keys:
         player_duration, player_current_time, player_playback_quality,
         player_movie_id, player_movie_title, player_url, player_state,
         player_ad_state, and player_breaks_count.
 
         For more information on the above keys and their values see
         `_yt_state_named_tuple`.
 
-        :return: A named tuple 'yt_state_info', derived from arguments and
+        :return: A named tuple 'player_state_info', derived from arguments and
         state information from the puppeteer.
         """
         player_state_info_kwargs['player_remaining_time'] = (
             self.expected_duration -
             player_state_info_kwargs['player_current_time'])
         # Calculate player state convenience info
         player_state = player_state_info_kwargs['player_state']
         player_state_info_kwargs['player_unstarted'] = (
@@ -364,17 +364,17 @@ class YouTubePuppeteer(VideoPuppeteer):
                 'player_state,'
                 'player_ad_state,'
                 'player_breaks_count];')
         return self._fetch_state_script_string
 
     def _refresh_state(self):
         """
         Refresh the snapshot of the underlying video and player state. We do
-        this allin one so that the state doesn't change in between queries.
+        this all in one so that the state doesn't change in between queries.
 
         We also store information that can be derived from the snapshotted
         information, such as lag. This is stored in the last seen state to
         stress that it's based on the snapshot.
         """
         values = self._execute_yt_script(self._fetch_state_script)
         video_keys = ['base_uri', 'current_time', 'duration',
                       'raw_buffered_ranges', 'raw_played_ranges',
@@ -467,30 +467,30 @@ class YouTubePuppeteer(VideoPuppeteer):
                     message = '\n'.join(['Playback stalled', str(self)])
                     raise VideoException(message)
             if self._last_seen_player_state.player_breaks_count > 0:
                 self.process_ad()
             if remaining_time > 1.5 * rest:
                 sleep(rest)
             else:
                 sleep(rest / 2)
-            # TODO during an ad, remaining_time will be based on ad's current_time
-            # rather than current_time of target video
+            # TODO during an ad, remaining_time will be based on ad's
+            # current_time rather than current_time of target video
             remaining_time = self._last_seen_player_state.player_remaining_time
         return remaining_time
 
     def __str__(self):
         messages = [super(YouTubePuppeteer, self).__str__()]
         if not self.player:
             messages += ['\t.html5-media-player: None']
             return '\n'.join(messages)
         if not self._last_seen_player_state:
             messages += ['\t.html5-media-player: No last seen state']
             return '\n'.join(messages)
         messages += ['.html5-media-player: {']
         for field in self._last_seen_player_state._fields:
             # For compatibility with different test environments we force ascii
             field_ascii = (
-                unicode(getattr(self._last_seen_player_state, field))
-                        .encode('ascii', 'replace'))
+                unicode(getattr(self._last_seen_player_state, field)).encode(
+                    'ascii', 'replace'))
             messages += [('\t{}: {}'.format(field, field_ascii))]
         messages += '}'
         return '\n'.join(messages)
new file mode 100644
--- /dev/null
+++ b/dom/media/test/external/external_media_tests/playback/twitch/manifest.ini
@@ -0,0 +1,1 @@
+[test_basic_stream_playback.py]
new file mode 100644
--- /dev/null
+++ b/dom/media/test/external/external_media_tests/playback/twitch/test_basic_stream_playback.py
@@ -0,0 +1,30 @@
+# 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/.
+
+from marionette_driver.errors import TimeoutException
+from marionette_harness import Marionette
+
+from external_media_harness.testcase import MediaTestCase
+from external_media_tests.media_utils.twitch_puppeteer import TwitchPuppeteer
+
+
+class TestBasicStreamPlayback(MediaTestCase):
+    def test_video_playback_partial(self):
+        """
+        Test to make sure that playback of 60 seconds works for each video.
+        """
+        with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
+            for url in self.video_urls:
+                stream = TwitchPuppeteer(self.marionette, url,
+                                         stall_wait_time=10,
+                                         set_duration=60)
+                self.run_playback(stream)
+
+    def test_playback_starts(self):
+        with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
+            for url in self.video_urls:
+                try:
+                    TwitchPuppeteer(self.marionette, url, timeout=60)
+                except TimeoutException as e:
+                    raise self.failureException(e)
new file mode 100644
--- /dev/null
+++ b/dom/media/test/external/external_media_tests/urls/twitch/default.ini
@@ -0,0 +1,1 @@
+[https://www.twitch.tv/food]
--- a/dom/notification/test/unit/xpcshell.ini
+++ b/dom/notification/test/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 head = common_test_notificationdb.js
-tail =
 skip-if = toolkit == 'android'
 
 [test_notificationdb.js]
 [test_notificationdb_bug1024090.js]
--- a/dom/plugins/test/unit/xpcshell.ini
+++ b/dom/plugins/test/unit/xpcshell.ini
@@ -1,12 +1,11 @@
 [DEFAULT]
 skip-if = toolkit == 'android'
 head = head_plugins.js
-tail =
 tags = addons
 firefox-appdir = browser
 support-files =
   !/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
 
 [test_allowed_types.js]
 skip-if = appname == "thunderbird"
 reason = plugins are disabled by default in Thunderbird
--- a/dom/presentation/tests/xpcshell/xpcshell.ini
+++ b/dom/presentation/tests/xpcshell/xpcshell.ini
@@ -1,9 +1,8 @@
 [DEFAULT]
 head =
-tail =
 
 [test_multicast_dns_device_provider.js]
 [test_presentation_device_manager.js]
 [test_presentation_session_transport.js]
 [test_tcp_control_channel.js]
 [test_presentation_state_machine.js]
--- a/dom/promise/tests/unit/xpcshell.ini
+++ b/dom/promise/tests/unit/xpcshell.ini
@@ -1,5 +1,4 @@
 [DEFAULT]
 head =
-tail =
 
 [test_monitor_uncaught.js]
--- a/dom/push/test/xpcshell/xpcshell.ini
+++ b/dom/push/test/xpcshell/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js head-http2.js
-tail =
 # Push notifications and alarms are currently disabled on Android.
 skip-if = toolkit == 'android'
 
 [test_clear_forgetAboutSite.js]
 [test_clear_origin_data.js]
 [test_crypto.js]
 [test_drop_expired.js]
 [test_handler_service.js]
--- a/dom/secureelement/tests/unit/xpcshell.ini
+++ b/dom/secureelement/tests/unit/xpcshell.ini
@@ -1,5 +1,4 @@
 [DEFAULT]
 head = header_helper.js
-tail =
 
-[test_SEUtils.js]
\ No newline at end of file
+[test_SEUtils.js]
--- a/dom/security/test/unit/xpcshell.ini
+++ b/dom/security/test/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 head =
-tail =
 
 [test_csp_reports.js]
 [test_isOriginPotentiallyTrustworthy.js]
 [test_csp_upgrade_insecure_request_header.js]
--- a/dom/system/gonk/tests/xpcshell.ini
+++ b/dom/system/gonk/tests/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = header_helpers.js
-tail =
 
 [test_ril_worker_buf.js]
 [test_ril_worker_icc_CardLock.js]
 [test_ril_worker_icc_CardState.js]
 [test_ril_worker_icc_BerTlvHelper.js]
 [test_ril_worker_icc_GsmPDUHelper.js]
 [test_ril_worker_icc_ICCContactHelper.js]
 [test_ril_worker_icc_ICCIOHelper.js]
--- a/dom/tests/unit/xpcshell.ini
+++ b/dom/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 
 [test_bug319968.js]
 [test_bug465752.js]
 [test_Fetch.js]
 [test_geolocation_provider.js]
 # Bug 684962: test hangs consistently on Android
 skip-if = os == "android"
 [test_geolocation_timeout.js]
--- a/dom/webidl/GetUserMediaRequest.webidl
+++ b/dom/webidl/GetUserMediaRequest.webidl
@@ -1,16 +1,25 @@
 /* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
 /* 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/.
  *
  * This is an internal IDL file
  */
 
+// for gUM request start (getUserMedia:request) notification,
+// rawID and mediaSource won't be set.
+// for gUM request stop (recording-device-stopped) notification due to page reload,
+// only windowID will be set.
+// for gUM request stop (recording-device-stopped) notification due to track stop,
+// only windowID, rawID and mediaSource will be set
+
 [NoInterfaceObject]
 interface GetUserMediaRequest {
   readonly attribute unsigned long long windowID;
   readonly attribute unsigned long long innerWindowID;
   readonly attribute DOMString callID;
+  readonly attribute DOMString rawID;
+  readonly attribute DOMString mediaSource;
   MediaStreamConstraints getConstraints();
   readonly attribute boolean isSecure;
 };
--- a/dom/workers/test/xpcshell/xpcshell.ini
+++ b/dom/workers/test/xpcshell/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 support-files =
   data/worker.js
   data/worker_fileReader.js
   data/chrome.manifest
 
 [test_workers.js]
 [test_fileReader.js]
--- a/extensions/cookie/test/unit/xpcshell.ini
+++ b/extensions/cookie/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_cookies.js
-tail =
 skip-if = toolkit == 'android'
 
 [test_bug526789.js]
 [test_bug650522.js]
 [test_bug667087.js]
 [test_cookies_async_failure.js]
 [test_cookies_persistence.js]
 skip-if = true # Bug 863738
--- a/extensions/cookie/test/unit_ipc/xpcshell.ini
+++ b/extensions/cookie/test/unit_ipc/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 head = 
-tail = 
 skip-if = toolkit == 'android'
 
 [test_child.js]
 [test_parent.js]
--- a/extensions/pref/autoconfig/test/unit/xpcshell.ini
+++ b/extensions/pref/autoconfig/test/unit/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 support-files =
   autoconfig-latin1.cfg
   autoconfig-utf8.cfg
   autoconfig.js
 
 [test_autoconfig_nonascii.js]
--- a/extensions/spellcheck/hunspell/tests/unit/xpcshell.ini
+++ b/extensions/spellcheck/hunspell/tests/unit/xpcshell.ini
@@ -1,7 +1,6 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 support-files = data/**
 
 [test_hunspell.js]
--- a/gfx/tests/unit/xpcshell.ini
+++ b/gfx/tests/unit/xpcshell.ini
@@ -1,5 +1,4 @@
 [DEFAULT]
 head = 
-tail = 
 
 [test_nsIScriptableRegion.js]
--- a/image/test/unit/xpcshell.ini
+++ b/image/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 support-files =
   async_load_tests.js
   bug413512.ico
   bug815359.ico
   image1.png
   image1png16x16.jpg
   image1png64x64.jpg
   image2.jpg
--- a/intl/locale/tests/unit/xpcshell.ini
+++ b/intl/locale/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = 
-tail = 
 support-files =
   data/intl_on_workers_worker.js
   data/chrome.manifest
 
 [test_bug22310.js]
 skip-if = toolkit != "windows" && toolkit != "cocoa"
 
 [test_bug371611.js]
--- a/intl/strres/tests/unit/xpcshell.ini
+++ b/intl/strres/tests/unit/xpcshell.ini
@@ -1,9 +1,8 @@
 [DEFAULT]
 head =
-tail =
 support-files =
   397093.properties
   strres.properties
 
 [test_bug378839.js]
 [test_bug397093.js]
--- a/intl/uconv/tests/unit/xpcshell.ini
+++ b/intl/uconv/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 support-files =
   CharsetConversionTests.js
   hangulTestStrings.js
   data/unicode-conversion.utf16.txt
   data/unicode-conversion.utf16be.txt
   data/unicode-conversion.utf16le.txt
   data/unicode-conversion.utf8.txt
 
--- a/intl/unicharutil/tests/unit/xpcshell.ini
+++ b/intl/unicharutil/tests/unit/xpcshell.ini
@@ -1,3 +1,2 @@
 [DEFAULT]
 head = 
-tail = 
--- a/ipc/testshell/tests/xpcshell.ini
+++ b/ipc/testshell/tests/xpcshell.ini
@@ -1,9 +1,8 @@
 [DEFAULT]
 head = 
-tail = 
 skip-if = toolkit == 'android'
 
 [test_ipcshell.js]
 # Bug 676963: test fails consistently on Android
 fail-if = os == "android"
 [test_ipcshell_child.js]
--- a/js/ductwork/debugger/tests/xpcshell.ini
+++ b/js/ductwork/debugger/tests/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head = head_dbg.js
-tail =
 skip-if = toolkit == 'android'
 
 [test_nativewrappers.js]
 # Bug 685068
 fail-if = os == "android"
--- a/js/xpconnect/tests/unit/xpcshell.ini
+++ b/js/xpconnect/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 support-files =
   CatRegistrationComponents.manifest
   bogus_element_type.jsm
   bogus_exports_type.jsm
   bug451678_subscript.js
   component-blob.js
   component-blob.manifest
   component-file.js
--- a/layout/reftests/w3c-css/submitted/check-for-references.sh
+++ b/layout/reftests/w3c-css/submitted/check-for-references.sh
@@ -1,14 +1,14 @@
 #!/bin/bash
 
 cd "$(dirname "$0")"
 find . -name reftest.list | sed 's,/reftest.list$,,' | while read DIRNAME
 do
-    cat "$DIRNAME/reftest.list" | grep -v "^\(include\|default-preferences\)" | sed 's/ #.*//;s/^#.*//;s/.* == /== /;s/.* != /!= /' | grep -v "^ *$" | while read TYPE TEST REF
+    cat "$DIRNAME/reftest.list" | grep -v -e "^default-preferences" -e "include " | sed 's/ #.*//;s/^#.*//;s/.* == /== /;s/.* != /!= /' | grep -v "^ *$" | while read TYPE TEST REF
     do
         REFTYPE=""
         if [ "$TYPE" == "==" ]
         then
             REFTYPE="match"
         elif [ "$TYPE" == "!=" ]
         then
             REFTYPE="mismatch"
--- a/layout/style/test/xpcshell.ini
+++ b/layout/style/test/xpcshell.ini
@@ -1,5 +1,4 @@
 [DEFAULT]
 head =
-tail =
 
 [test_csslexer.js]
--- a/layout/tools/layout-debug/tests/unit/xpcshell.ini
+++ b/layout/tools/layout-debug/tests/unit/xpcshell.ini
@@ -1,5 +1,4 @@
 [DEFAULT]
 head = 
-tail = 
 
 [test_componentsRegistered.js]
--- a/mobile/android/base/java/org/mozilla/gecko/Tab.java
+++ b/mobile/android/base/java/org/mozilla/gecko/Tab.java
@@ -32,16 +32,17 @@ import org.mozilla.gecko.widget.SiteLogi
 
 import android.content.ContentResolver;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.Color;
 import android.graphics.drawable.BitmapDrawable;
 import android.os.Build;
 import android.os.Bundle;
+import android.support.annotation.NonNull;
 import android.text.TextUtils;
 import android.util.Log;
 import android.view.View;
 
 public class Tab {
     private static final String LOGTAG = "GeckoTab";
 
     private static Pattern sColorPattern;
@@ -428,23 +429,23 @@ public class Tab {
     public void setHasTouchListeners(boolean aValue) {
         mHasTouchListeners = aValue;
     }
 
     public boolean getHasTouchListeners() {
         return mHasTouchListeners;
     }
 
-    public synchronized void addFavicon(String faviconURL, int faviconSize, String mimeType) {
+    public synchronized void addFavicon(@NonNull String faviconURL, int faviconSize, String mimeType) {
         mIconRequestBuilder
                 .icon(IconDescriptor.createFavicon(faviconURL, faviconSize, mimeType))
                 .deferBuild();
     }
 
-    public synchronized void addTouchicon(String iconUrl, int faviconSize, String mimeType) {
+    public synchronized void addTouchicon(@NonNull String iconUrl, int faviconSize, String mimeType) {
         mIconRequestBuilder
                 .icon(IconDescriptor.createTouchicon(iconUrl, faviconSize, mimeType))
                 .deferBuild();
     }
 
     public void loadFavicon() {
         // Static Favicons never change
         if (AboutPages.isBuiltinIconPage(mUrl) && mFavicon != null) {
--- a/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java
@@ -1,16 +1,17 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * 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/. */
 
 package org.mozilla.gecko.icons;
 
 import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.annotation.VisibleForTesting;
 
 /**
  * A class describing the location and properties of an icon that can be loaded.
  */
 public class IconDescriptor {
     @IntDef({ TYPE_GENERIC, TYPE_FAVICON, TYPE_TOUCHICON, TYPE_LOOKUP })
@@ -25,44 +26,44 @@ public class IconDescriptor {
     private final String url;
     private final int size;
     private final String mimeType;
     private final int type;
 
     /**
      * Create a generic icon located at the given URL. No MIME type or size is known.
      */
-    public static IconDescriptor createGenericIcon(String url) {
+    public static IconDescriptor createGenericIcon(@NonNull String url) {
         return new IconDescriptor(TYPE_GENERIC, url, 0, null);
     }
 
     /**
      * Create a favicon located at the given URL and with a known size and MIME type.
      */
-    public static IconDescriptor createFavicon(String url, int size, String mimeType) {
+    public static IconDescriptor createFavicon(@NonNull String url, int size, String mimeType) {
         return new IconDescriptor(TYPE_FAVICON, url, size, mimeType);
     }
 
     /**
      * Create a touch icon located at the given URL and with a known MIME type and size.
      */
-    public static IconDescriptor createTouchicon(String url, int size, String mimeType) {
+    public static IconDescriptor createTouchicon(@NonNull String url, int size, String mimeType) {
         return new IconDescriptor(TYPE_TOUCHICON, url, size, mimeType);
     }
 
     /**
      * Create an icon located at an URL that has been returned from a disk or memory storage. This
      * is an icon with an URL we loaded an icon from previously. Therefore we give it a little higher
      * ranking than a generic icon - even though we do not know the MIME type or size of the icon.
      */
-    public static IconDescriptor createLookupIcon(String url) {
+    public static IconDescriptor createLookupIcon(@NonNull String url) {
         return new IconDescriptor(TYPE_LOOKUP, url, 0, null);
     }
 
-    private IconDescriptor(@IconType int type, String url, int size, String mimeType) {
+    private IconDescriptor(@IconType int type, @NonNull String url, int size, String mimeType) {
         this.type = type;
         this.url = url;
         this.size = size;
         this.mimeType = mimeType;
     }
 
     /**
      * Get the URL of the icon.
--- a/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java
@@ -1,16 +1,17 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * 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/. */
 
 package org.mozilla.gecko.icons.storage;
 
 import android.os.SystemClock;
+import android.support.annotation.NonNull;
 import android.support.annotation.VisibleForTesting;
 import android.util.LruCache;
 
 /**
  * In-memory cache to remember URLs from which loading icons has failed recently.
  */
 public class FailureCache {
     /**
@@ -34,24 +35,24 @@ public class FailureCache {
 
     private FailureCache() {
         cache = new LruCache<>(MAX_ENTRIES);
     }
 
     /**
      * Remember this icon URL after loading from it (over the network) has failed.
      */
-    public void rememberFailure(String iconUrl) {
+    public void rememberFailure(@NonNull String iconUrl) {
         cache.put(iconUrl, SystemClock.elapsedRealtime());
     }
 
     /**
      * Has loading from this URL failed previously and recently?
      */
-    public boolean isKnownFailure(String iconUrl) {
+    public boolean isKnownFailure(@NonNull String iconUrl) {
         synchronized (cache) {
             final Long failedAt = cache.get(iconUrl);
             if (failedAt == null) {
                 return false;
             }
 
             if (failedAt + FAILURE_RETRY_MILLISECONDS < SystemClock.elapsedRealtime()) {
                 // The wait time has passed and we can retry loading from this URL.
--- a/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java
+++ b/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java
@@ -119,17 +119,19 @@ public final class CodecProxy {
         mOutputSurface = surface;
         mRemoteDrmStubId = drmStubId;
         mCallbacks = new CallbacksForwarder(callbacks);
     }
 
     boolean init(ICodec remote) {
         try {
             remote.setCallbacks(mCallbacks);
-            remote.configure(mFormat, mOutputSurface, 0, mRemoteDrmStubId);
+            if (!remote.configure(mFormat, mOutputSurface, 0, mRemoteDrmStubId)) {
+                return false;
+            }
             remote.start();
         } catch (RemoteException e) {
             e.printStackTrace();
             return false;
         }
 
         mRemote = remote;
         return true;
--- a/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java
@@ -15,16 +15,17 @@ import org.mozilla.gecko.widget.FaviconV
 
 import android.app.Activity;
 import android.app.AlertDialog;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.drawable.BitmapDrawable;
 import android.support.design.widget.Snackbar;
 import android.text.SpannableString;
+import android.text.TextUtils;
 import android.util.Log;
 import android.view.View;
 
 /**
  * Represents an element in the list of search engines on the preferences menu.
  */
 public class SearchEnginePreference extends CustomListPreference {
     protected String LOGTAG = "SearchEnginePreference";
@@ -150,16 +151,20 @@ public class SearchEnginePreference exte
         }
 
         final String engineName = geckoEngine.getString("name");
         final SpannableString titleSpannable = new SpannableString(engineName);
 
         setTitle(titleSpannable);
 
         final String iconURI = geckoEngine.getString("iconURI");
+        if (TextUtils.isEmpty(iconURI)) {
+            return;
+        }
+
         // Keep a reference to the bitmap - we'll need it later in onBindView.
         try {
             Icons.with(getContext())
                     .pageUrl(mIdentifier)
                     .icon(IconDescriptor.createGenericIcon(iconURI))
                     .privileged(true)
                     .build()
                     .execute(new IconCallback() {
--- a/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java
+++ b/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java
@@ -1,21 +1,23 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * 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/. */
 package org.mozilla.gecko.util;
 
+import android.annotation.TargetApi;
 import android.content.res.TypedArray;
 import android.os.Build;
 import android.support.v4.text.TextUtilsCompat;
 import android.support.v4.view.MarginLayoutParamsCompat;
 import android.support.v4.view.ViewCompat;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.TextView;
 
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.R;
 
 import java.util.Locale;
 
 public class ViewUtil {
 
@@ -67,9 +69,30 @@ public class ViewUtil {
                 ViewCompat.setLayoutDirection(view, ViewCompat.LAYOUT_DIRECTION_RTL);
                 break;
             case ViewCompat.LAYOUT_DIRECTION_LTR:
             default:
                 ViewCompat.setLayoutDirection(view, ViewCompat.LAYOUT_DIRECTION_LTR);
                 break;
         }
     }
+
+    /**
+     * RTL compatibility wrapper to force set TextDirection for JB mr1 and above
+     *
+     * @param textView
+     * @param isRtl
+     */
+    public static void setTextDirectionRtlCompat(TextView textView, boolean isRtl) {
+        if (AppConstants.Versions.feature17Plus) {
+            setTextDirectionRtlCompat17(textView, isRtl);
+        }
+    }
+
+    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
+    private static void setTextDirectionRtlCompat17(TextView textView, boolean isRtl) {
+        if (isRtl) {
+            textView.setTextDirection(View.TEXT_DIRECTION_RTL);
+        } else {
+            textView.setTextDirection(View.TEXT_DIRECTION_LTR);
+        }
+    }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java
@@ -5,19 +5,22 @@
 
 package org.mozilla.gecko.widget;
 
 import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.LinearGradient;
 import android.graphics.Shader;
 import android.support.v4.text.BidiFormatter;
+import android.text.TextUtils;
 import android.util.AttributeSet;
 import android.view.View;
 
+import org.mozilla.gecko.util.ViewUtil;
+
 /**
  * Fades the end of the text by gecko:fadeWidth amount,
  * if the text is too long and requires an ellipsis.
  *
  * This implementation is an improvement over Android's built-in fadingEdge
  * and the fastest of Fennec's implementations. However, it only works for
  * text of one color. It works by applying a linear gradient directly to the text.
  */
@@ -44,20 +47,24 @@ public class FadedSingleColorTextView ex
         }
 
         getPaint().setShader(needsEllipsis ? mTextGradient : null);
     }
 
     @Override
     public void setText(CharSequence text, BufferType type) {
         super.setText(text, type);
-        mIsTextDirectionRtl = BidiFormatter.getInstance().isRtl((String) text);
-        if (mIsTextDirectionRtl) {
-            setTextDirection(TEXT_DIRECTION_RTL);
+        final boolean previousTextDirectionRtl = mIsTextDirectionRtl;
+        if (!TextUtils.isEmpty(text)) {
+            mIsTextDirectionRtl = BidiFormatter.getInstance().isRtl((String) text);
         }
+        if (mIsTextDirectionRtl != previousTextDirectionRtl) {
+            mTextGradient = null;
+        }
+        ViewUtil.setTextDirectionRtlCompat(this, mIsTextDirectionRtl);
     }
 
     @Override
     public void onDraw(Canvas canvas) {
         updateGradientShader();
         super.onDraw(canvas);
     }
 
--- a/modules/libjar/test/unit/xpcshell.ini
+++ b/modules/libjar/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 support-files =
   data/empty
   data/test_bug333423.zip
   data/test_bug336691.zip
   data/test_bug370103.jar
   data/test_bug379841.zip
   data/test_bug589292.zip
--- a/modules/libjar/zipwriter/test/unit/xpcshell.ini
+++ b/modules/libjar/zipwriter/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_zipwriter.js
-tail =
 support-files =
   data/test_bug446708/thumbs/st14-1.tiff
   data/emptyfile.txt
   data/smallfile.txt
   data/test.png
   data/test.txt
   data/test.zip
   data/test_bug399727.html
--- a/modules/libmar/tests/unit/xpcshell.ini
+++ b/modules/libmar/tests/unit/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head = head_libmar.js
-tail =
 support-files = data/**
 
 [test_create.js]
 [test_extract.js]
 [test_sign_verify.js]
--- a/modules/libpref/test/unit/xpcshell.ini
+++ b/modules/libpref/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_libPrefs.js
-tail =
 support-files =
   data/testPref.js
   extdata/testExt.js
 
 [test_warnings.js]
 [test_bug345529.js]
 [test_bug506224.js]
 [test_bug577950.js]
--- a/modules/libpref/test/unit_ipc/xpcshell.ini
+++ b/modules/libpref/test/unit_ipc/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = 
-tail = 
 skip-if = toolkit == 'android'
 
 [test_existing_prefs.js]
 [test_initial_prefs.js]
 [test_large_pref.js]
 [test_observed_prefs.js]
 [test_update_prefs.js]
 [test_user_default_prefs.js]
--- a/netwerk/cookie/test/unit/xpcshell.ini
+++ b/netwerk/cookie/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = 
-tail = 
 
 [test_bug643051.js]
 [test_bug1155169.js]
 [test_bug1267910.js]
 [test_bug1321912.js]
 [test_parser_0001.js]
 [test_parser_0019.js]
 [test_eviction.js]
--- a/netwerk/cookie/test/unit_ipc/xpcshell.ini
+++ b/netwerk/cookie/test/unit_ipc/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head = 
-tail = 
 skip-if = toolkit == 'android'
 support-files =
   !/netwerk/cookie/test/unit/test_parser_0001.js
   !/netwerk/cookie/test/unit/test_parser_0019.js
 
 [test_ipc_parser_0001.js]
 [test_ipc_parser_0019.js]
--- a/netwerk/test/httpserver/test/xpcshell.ini
+++ b/netwerk/test/httpserver/test/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_utils.js
-tail =
 support-files = data/** ../httpd.js
 
 [test_async_response_sending.js]
 [test_basic_functionality.js]
 [test_body_length.js]
 [test_byte_range.js]
 [test_cern_meta.js]
 [test_default_index_handler.js]
--- a/netwerk/test/unit/xpcshell.ini
+++ b/netwerk/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_channels.js head_cache.js head_cache2.js
-tail =
 support-files =
   CA.cert.der
   client_cert_chooser.js
   client_cert_chooser.manifest
   data/image.png
   data/system_root.lnk
   data/test_psl.txt
   data/test_readline1.txt
--- a/netwerk/test/unit_ipc/xpcshell.ini
+++ b/netwerk/test/unit_ipc/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_channels_clone.js head_cc.js
-tail =
 skip-if = toolkit == 'android'
 support-files =
   child_channel_id.js
   !/netwerk/test/unit/test_XHR_redirects.js
   !/netwerk/test/unit/test_bug248970_cookie.js
   !/netwerk/test/unit/test_bug528292.js
   !/netwerk/test/unit/test_cache_jar.js
   !/netwerk/test/unit/test_cacheflags.js
--- a/parser/xml/test/unit/xpcshell.ini
+++ b/parser/xml/test/unit/xpcshell.ini
@@ -1,9 +1,8 @@
 [DEFAULT]
 head =
-tail =
 support-files = results.js
 
 [test_parser.js]
 [test_namespace_support.js]
 [test_xml_declaration.js]
 [test_sanitizer.js]
--- a/python/mozbuild/mozbuild/artifacts.py
+++ b/python/mozbuild/mozbuild/artifacts.py
@@ -439,22 +439,22 @@ class WinArtifactJob(ArtifactJob):
 # The values correpsond to a pair of (<package regex>, <test archive regex>).
 JOB_DETAILS = {
     'android-api-15-opt': (AndroidArtifactJob, (r'(public/build/fennec-(.*)\.android-arm.apk|public/build/target\.apk)',
                                                 r'public/build/fennec-(.*)\.common\.tests\.zip|public/build/target-(.*)\.common\.tests\.zip')),
     'android-api-15-debug': (AndroidArtifactJob, (r'public/build/target\.apk',
                                                   r'public/build/target\.common\.tests\.zip')),
     'android-x86-opt': (AndroidArtifactJob, (r'public/build/target\.apk',
                                              r'public/build/target\.common\.tests\.zip')),
-    'linux-opt': (LinuxArtifactJob, (r'public/build/firefox-(.*)\.linux-i686\.tar\.bz2',
-                                     r'public/build/firefox-(.*)\.common\.tests\.zip')),
-    'linux-debug': (LinuxArtifactJob, (r'public/build/firefox-(.*)\.linux-i686\.tar\.bz2',
-                                       r'public/build/firefox-(.*)\.common\.tests\.zip')),
-    'linux64-opt': (LinuxArtifactJob, (r'public/build/firefox-(.*)\.linux-x86_64\.tar\.bz2',
-                                       r'public/build/firefox-(.*)\.common\.tests\.zip')),
+    'linux-opt': (LinuxArtifactJob, (r'public/build/target\.tar\.bz2',
+                                     r'public/build/target\.common\.tests\.zip')),
+    'linux-debug': (LinuxArtifactJob, (r'public/build/target\.tar\.bz2',
+                                       r'public/build/target\.common\.tests\.zip')),
+    'linux64-opt': (LinuxArtifactJob, (r'public/build/target\.tar\.bz2',
+                                       r'public/build/target\.common\.tests\.zip')),
     'linux64-debug': (LinuxArtifactJob, (r'public/build/target\.tar\.bz2',
                                          r'public/build/target\.common\.tests\.zip')),
     'macosx64-opt': (MacArtifactJob, (r'public/build/firefox-(.*)\.mac\.dmg',
                                       r'public/build/firefox-(.*)\.common\.tests\.zip')),
     'macosx64-debug': (MacArtifactJob, (r'public/build/firefox-(.*)\.mac\.dmg',
                                         r'public/build/firefox-(.*)\.common\.tests\.zip')),
     'win32-opt': (WinArtifactJob, (r'public/build/firefox-(.*)\.win32.zip',
                                    r'public/build/firefox-(.*)\.common\.tests\.zip')),
--- a/python/mozbuild/mozbuild/frontend/emitter.py
+++ b/python/mozbuild/mozbuild/frontend/emitter.py
@@ -1346,17 +1346,16 @@ class TreeMetadataEmitter(LoggingMixin):
 
         for test, source_manifest in sorted(manifest.tests):
             obj.tests.append({
                 'path': test,
                 'here': mozpath.dirname(test),
                 'manifest': source_manifest,
                 'name': mozpath.basename(test),
                 'head': '',
-                'tail': '',
                 'support-files': '',
                 'subsuite': '',
             })
 
         yield obj
 
     def _process_web_platform_tests_manifest(self, context, paths, manifest):
         manifest_path, tests_root = paths
@@ -1381,17 +1380,16 @@ class TreeMetadataEmitter(LoggingMixin):
 
             for test in tests:
                 obj.tests.append({
                     'path': path,
                     'here': mozpath.dirname(path),
                     'manifest': manifest_path,
                     'name': test.id,
                     'head': '',
-                    'tail': '',
                     'support-files': '',
                     'subsuite': '',
                 })
 
         yield obj
 
     def _process_jar_manifests(self, context):
         jar_manifests = context.get('JAR_MANIFESTS', [])
--- a/python/mozbuild/mozbuild/test/frontend/data/files-test-metadata/default/tests/xpcshell/xpcshell.ini
+++ b/python/mozbuild/mozbuild/test/frontend/data/files-test-metadata/default/tests/xpcshell/xpcshell.ini
@@ -1,1 +1,1 @@
-[test_default_mod.js]
\ No newline at end of file
+[test_default_mod.js]
--- a/python/mozbuild/mozbuild/test/frontend/data/files-test-metadata/tagged/tests/xpcshell.ini
+++ b/python/mozbuild/mozbuild/test/frontend/data/files-test-metadata/tagged/tests/xpcshell.ini
@@ -1,1 +1,1 @@
-[test_bar.js]
\ No newline at end of file
+[test_bar.js]
--- a/python/mozbuild/mozbuild/test/frontend/data/test-manifest-keys-extracted/xpcshell.ini
+++ b/python/mozbuild/mozbuild/test/frontend/data/test-manifest-keys-extracted/xpcshell.ini
@@ -1,6 +1,5 @@
 [DEFAULT]
 head = head1 head2
-tail = tail1 tail2
 dupe-manifest =
 
 [test_xpcshell.js]
--- a/python/mozbuild/mozbuild/test/frontend/test_emitter.py
+++ b/python/mozbuild/mozbuild/test/frontend/test_emitter.py
@@ -595,18 +595,16 @@ class TestEmitterBasic(unittest.TestCase
             'xpcshell.ini': {
                 'flavor': 'xpcshell',
                 'dupe': True,
                 'installs': {
                     'xpcshell.ini': False,
                     'test_xpcshell.js': True,
                     'head1': False,
                     'head2': False,
-                    'tail1': False,
-                    'tail2': False,
                 },
             },
             'reftest.list': {
                 'flavor': 'reftest',
                 'installs': {},
             },
             'crashtest.list': {
                 'flavor': 'crashtest',
--- a/python/mozbuild/mozbuild/test/test_testing.py
+++ b/python/mozbuild/mozbuild/test/test_testing.py
@@ -43,32 +43,30 @@ ALL_TESTS = {
             "firefox-appdir": "browser",
             "flavor": "xpcshell",
             "head": "head_global.js head_helpers.js head_http.js",
             "here": "/Users/gps/src/firefox/services/common/tests/unit",
             "manifest": "/Users/gps/src/firefox/services/common/tests/unit/xpcshell.ini",
             "name": "test_async_chain.js",
             "path": "/Users/gps/src/firefox/services/common/tests/unit/test_async_chain.js",
             "relpath": "test_async_chain.js",
-            "tail": ""
         }
     ],
     "services/common/tests/unit/test_async_querySpinningly.js": [
         {
             "dir_relpath": "services/common/tests/unit",
             "file_relpath": "services/common/tests/unit/test_async_querySpinningly.js",
             "firefox-appdir": "browser",
             "flavor": "xpcshell",
             "head": "head_global.js head_helpers.js head_http.js",
             "here": "/Users/gps/src/firefox/services/common/tests/unit",
             "manifest": "/Users/gps/src/firefox/services/common/tests/unit/xpcshell.ini",
             "name": "test_async_querySpinningly.js",
             "path": "/Users/gps/src/firefox/services/common/tests/unit/test_async_querySpinningly.js",
             "relpath": "test_async_querySpinningly.js",
-            "tail": ""
         }
     ],
    "toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js": [
         {
             "dir_relpath": "toolkit/mozapps/update/test/unit",
             "file_relpath": "toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
             "flavor": "xpcshell",
             "generated-files": "head_update.js",
@@ -76,33 +74,31 @@ ALL_TESTS = {
             "here": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit",
             "manifest": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/xpcshell_updater.ini",
             "name": "test_0201_app_launch_apply_update.js",
             "path": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
             "reason": "bug 820380",
             "relpath": "test_0201_app_launch_apply_update.js",
             "run-sequentially": "Launches application.",
             "skip-if": "toolkit == 'gonk' || os == 'android'",
-            "tail": ""
         },
         {
             "dir_relpath": "toolkit/mozapps/update/test/unit",
             "file_relpath": "toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
             "flavor": "xpcshell",
             "generated-files": "head_update.js",
             "head": "head_update.js head2.js",
             "here": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit",
             "manifest": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/xpcshell_updater.ini",
             "name": "test_0201_app_launch_apply_update.js",
             "path": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
             "reason": "bug 820380",
             "relpath": "test_0201_app_launch_apply_update.js",
             "run-sequentially": "Launches application.",
             "skip-if": "toolkit == 'gonk' || os == 'android'",
-            "tail": ""
         }
     ],
     "mobile/android/tests/background/junit3/src/common/TestAndroidLogWriters.java": [
         {
             "dir_relpath": "mobile/android/tests/background/junit3/src/common",
             "file_relpath": "mobile/android/tests/background/junit3/src/common/TestAndroidLogWriters.java",
             "flavor": "instrumentation",
             "here": "/Users/nalexander/Mozilla/gecko-dev/mobile/android/tests/background/junit3",
--- a/python/mozbuild/mozbuild/testing.py
+++ b/python/mozbuild/mozbuild/testing.py
@@ -335,17 +335,16 @@ class SupportFilesConverter(object):
     moz.build and returns the installs to perform for this test object.
 
     Processing the same support files multiple times will not have any further
     effect, and the structure of the parsed objects from manifests will have a
     lot of repeated entries, so this class takes care of memoizing.
     """
     def __init__(self):
         self._fields = (('head', set()),
-                        ('tail', set()),
                         ('support-files', set()),
                         ('generated-files', set()))
 
     def convert_support_files(self, test, install_root, manifest_dir, out_dir):
         # Arguments:
         #  test - The test object to process.
         #  install_root - The directory under $objdir/_tests that will contain
         #                 the tests for this harness (examples are "testing/mochitest",
@@ -377,17 +376,17 @@ class SupportFilesConverter(object):
 
                 if field == 'generated-files':
                     info.external_installs.add(mozpath.normpath(mozpath.join(out_dir, pattern)))
                 # '!' indicates our syntax for inter-directory support file
                 # dependencies. These receive special handling in the backend.
                 elif pattern[0] == '!':
                     info.deferred_installs.add(pattern)
                 # We only support globbing on support-files because
-                # the harness doesn't support * for head and tail.
+                # the harness doesn't support * for head.
                 elif '*' in pattern and field == 'support-files':
                     info.pattern_installs.append((manifest_dir, pattern, out_dir))
                 # "absolute" paths identify files that are to be
                 # placed in the install_root directory (no globs)
                 elif pattern[0] == '/':
                     full = mozpath.normpath(mozpath.join(manifest_dir,
                                                          mozpath.basename(pattern)))
                     info.installs.append((full, mozpath.join(install_root, pattern[1:])))
--- a/rdf/tests/unit/xpcshell.ini
+++ b/rdf/tests/unit/xpcshell.ini
@@ -1,6 +1,5 @@
 [DEFAULT]
 head =
-tail =
 support-files = sample.rdf
 
 [test_rdfredirect.js]
--- a/security/manager/ssl/tests/unit/xpcshell.ini
+++ b/security/manager/ssl/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_psm.js
-tail =
 tags = psm
 support-files =
   bad_certs/**
   ocsp_certs/**
   test_baseline_requirements/**
   test_cert_eku/**
   test_cert_embedded_null/**
   test_cert_isBuiltInRoot_reload/**
--- a/services/cloudsync/tests/xpcshell/xpcshell.ini
+++ b/services/cloudsync/tests/xpcshell/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head = head.js
-tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android'
 
 [test_module.js]
 [test_tabs.js]
 [test_bookmarks.js]
 [test_lazyload.js]
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_global.js head_helpers.js head_http.js
-tail =
 firefox-appdir = browser
 support-files =
   test_storage_adapter/**
   test_blocklist_signatures/**
 
 # Test load modules first so syntax failures are caught early.
 [test_load_modules.js]
 
--- a/services/crypto/component/tests/unit/xpcshell.ini
+++ b/services/crypto/component/tests/unit/xpcshell.ini
@@ -1,6 +1,5 @@
 [DEFAULT]
 head = 
-tail = 
 firefox-appdir = browser
 
 [test_jpake.js]
--- a/services/crypto/tests/unit/xpcshell.ini
+++ b/services/crypto/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_helpers.js ../../../common/tests/unit/head_helpers.js
-tail =
 firefox-appdir = browser
 support-files =
   !/services/common/tests/unit/head_helpers.js
 
 [test_load_modules.js]
 
 [test_crypto_crypt.js]
 [test_crypto_deriveKey.js]
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -41,16 +41,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 var publicProperties = [
   "accountStatus",
   "checkVerificationStatus",
   "getAccountsClient",
   "getAssertion",
   "getDeviceId",
   "getKeys",
   "getOAuthToken",
+  "getProfileCache",
   "getSignedInUser",
   "getSignedInUserProfile",
   "handleDeviceDisconnection",
   "invalidateCertificate",
   "loadAndPoll",
   "localtimeOffsetMsec",
   "notifyDevices",
   "now",
@@ -59,16 +60,17 @@ var publicProperties = [
   "promiseAccountsManageURI",
   "promiseAccountsSignUpURI",
   "promiseAccountsSignInURI",
   "removeCachedOAuthToken",
   "requiresHttps",
   "resendVerificationEmail",
   "resetCredentials",
   "sessionStatus",
+  "setProfileCache",
   "setSignedInUser",
   "signOut",
   "updateDeviceRegistration",
   "updateUserAccountData",
   "whenVerified",
 ];
 
 // An AccountState object holds all state related to one specific account.
@@ -1572,16 +1574,27 @@ FxAccountsInternal.prototype = {
     FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
     FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
     FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);
 
     let currentState = this.currentAccountState;
     return currentState.updateUserAccountData(updateData);
   },
 
+  getProfileCache() {
+    return this.currentAccountState.getUserAccountData(["profileCache"])
+      .then(data => data ? data.profileCache : null);
+  },
+
+  setProfileCache(profileCache) {
+    return this.currentAccountState.updateUserAccountData({
+      profileCache
+    });
+  },
+
   // If you change what we send to the FxA servers during device registration,
   // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
   // devices to re-register when Firefox updates
   _registerOrUpdateDevice(signedInUser) {
     try {
       // Allow tests to skip device registration because:
       //   1. It makes remote requests to the auth server.
       //   2. _getDeviceName does not work from xpcshell.
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -230,17 +230,17 @@ exports.ERROR_MSG_METHOD_NOT_ALLOWED    
 // JSON file in the profile dir and in the login manager.
 // In order to prevent new fields accidentally ending up in the "wrong" place,
 // all fields stored are listed here.
 
 // The fields we save in the plaintext JSON.
 // See bug 1013064 comments 23-25 for why the sessionToken is "safe"
 exports.FXA_PWDMGR_PLAINTEXT_FIELDS = new Set(
   ["email", "verified", "authAt", "sessionToken", "uid", "oauthTokens", "profile",
-  "deviceId", "deviceRegistrationVersion"]);
+  "deviceId", "deviceRegistrationVersion", "profileCache"]);
 
 // Fields we store in secure storage if it exists.
 exports.FXA_PWDMGR_SECURE_FIELDS = new Set(
   ["kA", "kB", "keyFetchToken", "unwrapBKey", "assertion"]);
 
 // Fields we keep in memory and don't persist anywhere.
 exports.FXA_PWDMGR_MEMORY_FIELDS = new Set(
   ["cert", "keyPair"]);
--- a/services/fxaccounts/FxAccountsProfile.jsm
+++ b/services/fxaccounts/FxAccountsProfile.jsm
@@ -19,67 +19,19 @@ const {classes: Cc, interfaces: Ci, util
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 Cu.import("resource://gre/modules/FxAccounts.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileClient",
   "resource://gre/modules/FxAccountsProfileClient.jsm");
 
-// Based off of deepEqual from Assert.jsm
-function deepEqual(actual, expected) {
-  if (actual === expected) {
-    return true;
-  } else if (typeof actual != "object" && typeof expected != "object") {
-    return actual == expected;
-  } else {
-    return objEquiv(actual, expected);
-  }
-}
-
-function isUndefinedOrNull(value) {
-  return value === null || value === undefined;
-}
-
-function objEquiv(a, b) {
-  if (isUndefinedOrNull(a) || isUndefinedOrNull(b)) {
-    return false;
-  }
-  if (a.prototype !== b.prototype) {
-    return false;
-  }
-  let ka, kb, key, i;
-  try {
-    ka = Object.keys(a);
-    kb = Object.keys(b);
-  } catch (e) {
-    return false;
-  }
-  if (ka.length != kb.length) {
-    return false;
-  }
-  ka.sort();
-  kb.sort();
-  for (i = ka.length - 1; i >= 0; i--) {
-    key = ka[i];
-    if (!deepEqual(a[key], b[key])) {
-      return false;
-    }
-  }
-  return true;
-}
-
-function hasChanged(oldData, newData) {
-  return !deepEqual(oldData, newData);
-}
-
 this.FxAccountsProfile = function(options = {}) {
-  this._cachedProfile = null;
+  this._currentFetchPromise = null;
   this._cachedAt = 0; // when we saved the cached version.
-  this._currentFetchPromise = null;
   this._isNotifying = false; // are we sending a notification?
   this.fxa = options.fxa || fxAccounts;
   this.client = options.profileClient || new FxAccountsProfileClient({
     fxa: this.fxa,
     serverURL: options.profileServerUrl,
   });
 
   // An observer to invalidate our _cachedAt optimization. We use a weak-ref
@@ -104,88 +56,89 @@ this.FxAccountsProfile.prototype = {
       log.debug("FxAccountsProfile observed profile change");
       this._cachedAt = 0;
     }
   },
 
   tearDown() {
     this.fxa = null;
     this.client = null;
-    this._cachedProfile = null;
     Services.obs.removeObserver(this, ON_PROFILE_CHANGE_NOTIFICATION);
   },
 
-  _getCachedProfile() {
-    // The cached profile will end up back in the generic accountData
-    // once bug 1157529 is fixed.
-    return Promise.resolve(this._cachedProfile);
-  },
-
   _notifyProfileChange(uid) {
     this._isNotifying = true;
     Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, uid);
     this._isNotifying = false;
   },
 
-  // Cache fetched data if it is different from what's in the cache.
-  // Send out a notification if it has changed so that UI can update.
-  _cacheProfile(profileData) {
-    if (!hasChanged(this._cachedProfile, profileData)) {
-      log.debug("fetched profile matches cached copy");
-      return Promise.resolve(null); // indicates no change (but only tests care)
-    }
-    this._cachedProfile = profileData;
+  // Cache fetched data and send out a notification so that UI can update.
+  _cacheProfile(response) {
     this._cachedAt = Date.now();
-    return this.fxa.getSignedInUser()
+    let profileCache = {
+      profile: response.body,
+      etag: response.etag
+    };
+
+    return this.fxa.setProfileCache(profileCache)
+      .then(() => {
+        return this.fxa.getSignedInUser();
+      })
       .then(userData => {
         log.debug("notifying profile changed for user ${uid}", userData);
         this._notifyProfileChange(userData.uid);
-        return profileData;
+        return response.body;
+      });
+  },
+
+  _fetchAndCacheProfileInternal() {
+    return this.fxa.getProfileCache()
+      .then(profileCache => {
+        const etag = profileCache ? profileCache.etag : null;
+        return this.client.fetchProfile(etag);
+      })
+      .then(response => {
+        return this._cacheProfile(response);
+      })
+      .then(body => { // finally block
+        this._currentFetchPromise = null;
+        return body;
+      }, e => {
+        this._currentFetchPromise = null;
+        throw e;
       });
   },
 
   _fetchAndCacheProfile() {
     if (!this._currentFetchPromise) {
-      this._currentFetchPromise = this.client.fetchProfile().then(profile => {
-        return this._cacheProfile(profile).then(() => {
-          return profile;
-        });
-      }).then(profile => {
-        this._currentFetchPromise = null;
-        return profile;
-      }, err => {
-        this._currentFetchPromise = null;
-        throw err;
-      });
+      this._currentFetchPromise = this._fetchAndCacheProfileInternal();
     }
-    return this._currentFetchPromise
+    return this._currentFetchPromise;
   },
 
   // Returns cached data right away if available, then fetches the latest profile
   // data in the background. After data is fetched a notification will be sent
   // out if the profile has changed.
   getProfile() {
-    return this._getCachedProfile()
-      .then(cachedProfile => {
-        if (cachedProfile) {
+    return this.fxa.getProfileCache()
+      .then(profileCache => {
+        if (profileCache) {
           if (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD) {
             // Note that _fetchAndCacheProfile isn't returned, so continues
             // in the background.
             this._fetchAndCacheProfile().catch(err => {
-              log.error("Background refresh of profile failed", err);
+              log.error("Background refresh of profile failed, bumping _cachedAt", err);
+              this._cachedAt = Date.now();
             });
           } else {
             log.trace("not checking freshness of profile as it remains recent");
           }
-          return cachedProfile;
+          return profileCache.profile;
         }
         return this._fetchAndCacheProfile();
-      })
-      .then(profile => {
-        return profile;
       });
   },
 
   QueryInterface: XPCOMUtils.generateQI([
       Ci.nsIObserver,
       Ci.nsISupportsWeakReference,
   ]),
 };
--- a/services/fxaccounts/FxAccountsProfileClient.jsm
+++ b/services/fxaccounts/FxAccountsProfileClient.jsm
@@ -70,45 +70,47 @@ this.FxAccountsProfileClient.prototype =
 
   /**
    * Remote request helper which abstracts authentication away.
    *
    * @param {String} path
    *        Profile server path, i.e "/profile".
    * @param {String} [method]
    *        Type of request, i.e "GET".
+   * @param {String} [etag]
+   *        Optional ETag used for caching purposes.
    * @return Promise
-   *         Resolves: {Object} Successful response from the Profile server.
+   *         Resolves: {body: Object, etag: Object} Successful response from the Profile server.
    *         Rejects: {FxAccountsProfileClientError} Profile client error.
    * @private
    */
-  _createRequest: Task.async(function* (path, method = "GET") {
+  _createRequest: Task.async(function* (path, method = "GET", etag = null) {
     let token = this.token;
     if (!token) {
       // tokens are cached, so getting them each request is cheap.
       token = yield this.fxa.getOAuthToken(this.oauthOptions);
     }
     try {
-      return (yield this._rawRequest(path, method, token));
+      return (yield this._rawRequest(path, method, token, etag));
     } catch (ex) {
       if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
         throw ex;
       }
       // If this object was instantiated with a token then we don't refresh it.
       if (this.token) {
         throw ex;
       }
       // it's an auth error - assume our token expired and retry.
       log.info("Fetching the profile returned a 401 - revoking our token and retrying");
       yield this.fxa.removeCachedOAuthToken({token});
       token = yield this.fxa.getOAuthToken(this.oauthOptions);
       // and try with the new token - if that also fails then we fail after
       // revoking the token.
       try {
-        return (yield this._rawRequest(path, method, token));
+        return (yield this._rawRequest(path, method, token, etag));
       } catch (ex) {
         if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
           throw ex;
         }
         log.info("Retry fetching the profile still returned a 401 - revoking our token and failing");
         yield this.fxa.removeCachedOAuthToken({token});
         throw ex;
       }
@@ -118,29 +120,33 @@ this.FxAccountsProfileClient.prototype =
   /**
    * Remote "raw" request helper - doesn't handle auth errors and tokens.
    *
    * @param {String} path
    *        Profile server path, i.e "/profile".
    * @param {String} method
    *        Type of request, i.e "GET".
    * @param {String} token
+   * @param {String} etag
    * @return Promise
-   *         Resolves: {Object} Successful response from the Profile server.
+   *         Resolves: {body: Object, etag: Object} Successful response from the Profile server.
    *         Rejects: {FxAccountsProfileClientError} Profile client error.
    * @private
    */
-  _rawRequest(path, method, token) {
+  _rawRequest(path, method, token, etag) {
     return new Promise((resolve, reject) => {
       let profileDataUrl = this.serverURL + path;
       let request = new this._Request(profileDataUrl);
       method = method.toUpperCase();
 
       request.setHeader("Authorization", "Bearer " + token);
       request.setHeader("Accept", "application/json");
+      if (etag) {
+        request.setHeader("If-None-Match", etag);
+      }
 
       request.onComplete = function(error) {
         if (error) {
           return reject(new FxAccountsProfileClientError({
             error: ERROR_NETWORK,
             errno: ERRNO_NETWORK,
             message: error.toString(),
           }));
@@ -155,17 +161,20 @@ this.FxAccountsProfileClient.prototype =
             errno: ERRNO_PARSE,
             code: request.response.status,
             message: request.response.body,
           }));
         }
 
         // "response.success" means status code is 200
         if (request.response.success) {
-          return resolve(body);
+          return resolve({
+            body,
+            etag: request.response.headers["etag"]
+          });
         } else {
           return reject(new FxAccountsProfileClientError({
             error: body.error || ERROR_UNKNOWN,
             errno: body.errno || ERRNO_UNKNOWN_ERROR,
             code: request.response.status,
             message: body.message || body,
           }));
         }
@@ -183,35 +192,25 @@ this.FxAccountsProfileClient.prototype =
         }));
       }
     });
   },
 
   /**
    * Retrieve user's profile from the server
    *
+   * @param {String} [etag]
+   *        Optional ETag used for caching purposes. (may generate a 304 exception)
    * @return Promise
-   *         Resolves: {Object} Successful response from the '/profile' endpoint.
+   *         Resolves: {body: Object, etag: Object} Successful response from the '/profile' endpoint.
    *         Rejects: {FxAccountsProfileClientError} profile client error.
    */
-  fetchProfile() {
+  fetchProfile(etag) {
     log.debug("FxAccountsProfileClient: Requested profile");
-    return this._createRequest("/profile", "GET");
-  },
-
-  /**
-   * Retrieve user's profile from the server
-   *
-   * @return Promise
-   *         Resolves: {Object} Successful response from the '/avatar' endpoint.
-   *         Rejects: {FxAccountsProfileClientError} profile client error.
-   */
-  fetchProfileImage() {
-    log.debug("FxAccountsProfileClient: Requested avatar");
-    return this._createRequest("/avatar", "GET");
+    return this._createRequest("/profile", "GET", etag);
   }
 };
 
 /**
  * Normalized profile client errors
  * @param {Object} [details]
  *        Error details object
  *   @param {number} [details.code]
--- a/services/fxaccounts/tests/xpcshell/test_profile.js
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -14,17 +14,17 @@ Services.prefs.setCharPref("identity.fxa
 const STATUS_SUCCESS = 200;
 
 /**
  * Mock request responder
  * @param {String} response
  *        Mocked raw response from the server
  * @returns {Function}
  */
-var mockResponse = function(response) {
+let mockResponse = function(response) {
   let Request = function(requestUri) {
     // Store the request uri so tests can inspect it
     Request._requestUri = requestUri;
     return {
       setHeader() {},
       head() {
         this.response = response;
         this.onComplete();
@@ -36,28 +36,28 @@ var mockResponse = function(response) {
 };
 
 /**
  * Mock request error responder
  * @param {Error} error
  *        Error object
  * @returns {Function}
  */
-var mockResponseError = function(error) {
+let mockResponseError = function(error) {
   return function() {
     return {
       setHeader() {},
       head() {
         this.onComplete(error);
       }
     };
   };
 };
 
-var mockClient = function(fxa) {
+let mockClient = function(fxa) {
   let options = {
     serverURL: "http://127.0.0.1:1111/v1",
     fxa,
   }
   return new FxAccountsProfileClient(options);
 };
 
 const ACCOUNT_DATA = {
@@ -71,20 +71,29 @@ FxaMock.prototype = {
     profile: null,
     get isCurrent() {
       return true;
     }
   },
 
   getSignedInUser() {
     return Promise.resolve(ACCOUNT_DATA);
+  },
+
+  getProfileCache() {
+    return Promise.resolve(this.profileCache);
+  },
+
+  setProfileCache(profileCache) {
+    this.profileCache = profileCache;
+    return Promise.resolve();
   }
 };
 
-var mockFxa = function() {
+let mockFxa = function() {
   return new FxaMock();
 };
 
 function CreateFxAccountsProfile(fxa = null, client = null) {
   if (!fxa) {
     fxa = mockFxa();
   }
   let options = {
@@ -92,111 +101,109 @@ function CreateFxAccountsProfile(fxa = n
     profileServerUrl: "http://127.0.0.1:1111/v1"
   }
   if (client) {
     options.profileClient = client;
   }
   return new FxAccountsProfile(options);
 }
 
-add_test(function getCachedProfile() {
-  let profile = CreateFxAccountsProfile();
-  // a little pointless until bug 1157529 is fixed...
-  profile._cachedProfile = { avatar: "myurl" };
-
-  return profile._getCachedProfile()
-    .then(function(cached) {
-      do_check_eq(cached.avatar, "myurl");
-      run_next_test();
-    });
-});
-
 add_test(function cacheProfile_change() {
+  let setProfileCacheCalled = false;
   let fxa = mockFxa();
-/* Saving profile data disabled - bug 1157529
-  let setUserAccountDataCalled = false;
-  fxa.setUserAccountData = function (data) {
-    setUserAccountDataCalled = true;
+  fxa.setProfileCache = (data) => {
+    setProfileCacheCalled = true;
     do_check_eq(data.profile.avatar, "myurl");
+    do_check_eq(data.etag, "bogusetag");
     return Promise.resolve();
-  };
-*/
+  }
   let profile = CreateFxAccountsProfile(fxa);
+  profile._cachedAt = 12345;
 
   makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function(subject, topic, data) {
     do_check_eq(data, ACCOUNT_DATA.uid);
-//    do_check_true(setUserAccountDataCalled); - bug 1157529
+    do_check_neq(profile._cachedAt, 12345, "cachedAt has been bumped");
+    do_check_true(setProfileCacheCalled);
     run_next_test();
   });
 
-  return profile._cacheProfile({ avatar: "myurl" });
-});
-
-add_test(function cacheProfile_no_change() {
-  let fxa = mockFxa();
-  let profile = CreateFxAccountsProfile(fxa)
-  profile._cachedProfile = { avatar: "myurl" };
-// XXX - saving is disabled (but we can leave that in for now as we are
-// just checking it is *not* called)
-  fxa.setSignedInUser = function(data) {
-    throw new Error("should not update account data");
-  };
-
-  return profile._cacheProfile({ avatar: "myurl" })
-    .then((result) => {
-      do_check_false(!!result);
-      run_next_test();
-    });
+  return profile._cacheProfile({ body: { avatar: "myurl" }, etag: "bogusetag" });
 });
 
 add_test(function fetchAndCacheProfile_ok() {
   let client = mockClient(mockFxa());
   client.fetchProfile = function() {
-    return Promise.resolve({ avatar: "myimg"});
+    return Promise.resolve({ body: { avatar: "myimg"} });
   };
   let profile = CreateFxAccountsProfile(null, client);
 
   profile._cacheProfile = function(toCache) {
-    do_check_eq(toCache.avatar, "myimg");
-    return Promise.resolve();
+    do_check_eq(toCache.body.avatar, "myimg");
+    return Promise.resolve(toCache.body);
   };
 
   return profile._fetchAndCacheProfile()
     .then(result => {
       do_check_eq(result.avatar, "myimg");
       run_next_test();
     });
 });
 
+add_test(function fetchAndCacheProfile_sendsETag() {
+  let fxa = mockFxa();
+  fxa.profileCache = { profile: {}, etag: "bogusETag" };
+  let client = mockClient(fxa);
+  client.fetchProfile = function(etag) {
+    do_check_eq(etag, "bogusETag");
+    return Promise.resolve({ body: { avatar: "myimg"} });
+  };
+  let profile = CreateFxAccountsProfile(fxa, client);
+
+  return profile._fetchAndCacheProfile()
+    .then(result => {
+      run_next_test();
+    });
+});
+
 // Check that a second profile request when one is already in-flight reuses
 // the in-flight one.
 add_task(function* fetchAndCacheProfileOnce() {
   // A promise that remains unresolved while we fire off 2 requests for
   // a profile.
   let resolveProfile;
   let promiseProfile = new Promise(resolve => {
     resolveProfile = resolve;
   });
   let numFetches = 0;
   let client = mockClient(mockFxa());
   client.fetchProfile = function() {
     numFetches += 1;
     return promiseProfile;
   };
-  let profile = CreateFxAccountsProfile(null, client);
+  let fxa = mockFxa();
+  fxa.getProfileCache = () => {
+    // We do this because we are gonna have a race condition and fetchProfile will
+    // not be called before we check numFetches.
+    return {
+      then(thenFunc) {
+        return thenFunc();
+      }
+    }
+  };
+  let profile = CreateFxAccountsProfile(fxa, client);
 
   let request1 = profile._fetchAndCacheProfile();
   let request2 = profile._fetchAndCacheProfile();
 
   // should be one request made to fetch the profile (but the promise returned
   // by it remains unresolved)
   do_check_eq(numFetches, 1);
 
   // resolve the promise.
-  resolveProfile({ avatar: "myimg"});
+  resolveProfile({ body: { avatar: "myimg"} });
 
   // both requests should complete with the same data.
   let got1 = yield request1;
   do_check_eq(got1.avatar, "myimg");
   let got2 = yield request1;
   do_check_eq(got2.avatar, "myimg");
 
   // and still only 1 request was made.
@@ -213,17 +220,27 @@ add_task(function* fetchAndCacheProfileO
     rejectProfile = reject;
   });
   let numFetches = 0;
   let client = mockClient(mockFxa());
   client.fetchProfile = function() {
     numFetches += 1;
     return promiseProfile;
   };
-  let profile = CreateFxAccountsProfile(null, client);
+  let fxa = mockFxa();
+  fxa.getProfileCache = () => {
+    // We do this because we are gonna have a race condition and fetchProfile will
+    // not be called before we check numFetches.
+    return {
+      then(thenFunc) {
+        return thenFunc();
+      }
+    }
+  };
+  let profile = CreateFxAccountsProfile(fxa, client);
 
   let request1 = profile._fetchAndCacheProfile();
   let request2 = profile._fetchAndCacheProfile();
 
   // should be one request made to fetch the profile (but the promise returned
   // by it remains unresolved)
   do_check_eq(numFetches, 1);
 
@@ -243,19 +260,19 @@ add_task(function* fetchAndCacheProfileO
     yield request2;
     throw new Error("should have rejected");
   } catch (ex) {
     if (ex != "oh noes") {
       throw ex;
     }
   }
 
-  // but a new request should work.
+  // but a new request should works.
   client.fetchProfile = function() {
-    return Promise.resolve({ avatar: "myimg"});
+    return Promise.resolve({body: { avatar: "myimg"}});
   };
 
   let got = yield profile._fetchAndCacheProfile();
   do_check_eq(got.avatar, "myimg");
 });
 
 // Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
 // last one doesn't kick off a new request to check the cached copy is fresh.
@@ -317,40 +334,38 @@ add_test(function tearDown_ok() {
 
   run_next_test();
 });
 
 add_test(function getProfile_ok() {
   let cachedUrl = "myurl";
   let didFetch = false;
 
-  let profile = CreateFxAccountsProfile();
-  profile._getCachedProfile = function() {
-    return Promise.resolve({ avatar: cachedUrl });
-  };
+  let fxa = mockFxa();
+  fxa.profileCache = { profile: { avatar: cachedUrl } };
+  let profile = CreateFxAccountsProfile(fxa);
 
   profile._fetchAndCacheProfile = function() {
     didFetch = true;
     return Promise.resolve();
   };
 
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, cachedUrl);
       do_check_true(didFetch);
       run_next_test();
     });
 });
 
 add_test(function getProfile_no_cache() {
   let fetchedUrl = "newUrl";
-  let profile = CreateFxAccountsProfile();
-  profile._getCachedProfile = function() {
-    return Promise.resolve();
-  };
+  let fxa = mockFxa();
+  fxa.profileCache = null;
+  let profile = CreateFxAccountsProfile(fxa);
 
   profile._fetchAndCacheProfile = function() {
     return Promise.resolve({ avatar: fetchedUrl });
   };
 
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, fetchedUrl);
@@ -359,21 +374,21 @@ add_test(function getProfile_no_cache() 
 });
 
 add_test(function getProfile_has_cached_fetch_deleted() {
   let cachedUrl = "myurl";
 
   let fxa = mockFxa();
   let client = mockClient(fxa);
   client.fetchProfile = function() {
-    return Promise.resolve({ avatar: null });
+    return Promise.resolve({ body: { avatar: null } });
   };
 
   let profile = CreateFxAccountsProfile(fxa, client);
-  profile._cachedProfile = { avatar: cachedUrl };
+  fxa.profileCache = { profile: { avatar: cachedUrl } };
 
 // instead of checking this in a mocked "save" function, just check after the
 // observer
   makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function(subject, topic, data) {
     profile.getProfile()
       .then(profileData => {
         do_check_null(profileData.avatar);
         run_next_test();
@@ -381,16 +396,32 @@ add_test(function getProfile_has_cached_
   });
 
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, "myurl");
     });
 });
 
+add_test(function getProfile_fetchAndCacheProfile_throws() {
+  let fxa = mockFxa();
+  fxa.profileCache = { profile: { avatar: "myimg" } };
+  let profile = CreateFxAccountsProfile(fxa);
+
+  profile._cachedAt = 12345;
+  profile._fetchAndCacheProfile = () => Promise.reject(new Error());
+
+  return profile.getProfile()
+    .then(result => {
+      do_check_neq(profile._cachedAt, 12345);
+      do_check_eq(result.avatar, "myimg");
+      run_next_test();
+    });
+});
+
 function run_test() {
   run_next_test();
 }
 
 function makeObserver(aObserveTopic, aObserveFunc) {
   let callback = function(aSubject, aTopic, aData) {
     log.debug("observed " + aTopic + " " + aData);
     if (aTopic == aObserveTopic) {
--- a/services/fxaccounts/tests/xpcshell/test_profile_client.js
+++ b/services/fxaccounts/tests/xpcshell/test_profile_client.js
@@ -9,17 +9,17 @@ Cu.import("resource://gre/modules/FxAcco
 const STATUS_SUCCESS = 200;
 
 /**
  * Mock request responder
  * @param {String} response
  *        Mocked raw response from the server
  * @returns {Function}
  */
-var mockResponse = function(response) {
+let mockResponse = function(response) {
   let Request = function(requestUri) {
     // Store the request uri so tests can inspect it
     Request._requestUri = requestUri;
     return {
       setHeader() {},
       get() {
         this.response = response;
         this.onComplete();
@@ -28,17 +28,17 @@ var mockResponse = function(response) {
   };
 
   return Request;
 };
 
 // A simple mock FxA that hands out tokens without checking them and doesn't
 // expect tokens to be revoked. We have specific token tests further down that
 // has more checks here.
-var mockFxa = {
+let mockFxa = {
   getOAuthToken(options) {
     do_check_eq(options.scope, "profile");
     return "token";
   }
 }
 
 const PROFILE_OPTIONS = {
   serverURL: "http://127.0.0.1:1111/v1",
@@ -46,42 +46,89 @@ const PROFILE_OPTIONS = {
 };
 
 /**
  * Mock request error responder
  * @param {Error} error
  *        Error object
  * @returns {Function}
  */
-var mockResponseError = function(error) {
+let mockResponseError = function(error) {
   return function() {
     return {
       setHeader() {},
       get() {
         this.onComplete(error);
       }
     };
   };
 };
 
 add_test(function successfulResponse() {
   let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
   let response = {
     success: true,
     status: STATUS_SUCCESS,
+    headers: { etag:"bogusETag" },
     body: "{\"email\":\"someone@restmail.net\",\"uid\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
   };
 
   client._Request = new mockResponse(response);
   client.fetchProfile()
     .then(
       function(result) {
         do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/profile");
-        do_check_eq(result.email, "someone@restmail.net");
-        do_check_eq(result.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+        do_check_eq(result.body.email, "someone@restmail.net");
+        do_check_eq(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+        do_check_eq(result.etag, "bogusETag");
+        run_next_test();
+      }
+    );
+});
+
+add_test(function setsIfNoneMatchETagHeader() {
+  let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+  let response = {
+    success: true,
+    status: STATUS_SUCCESS,
+    headers: {},
+    body: "{\"email\":\"someone@restmail.net\",\"uid\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+  };
+
+  let ifNoneMatchSet = false;
+
+  let mockResponse = function(response) {
+    let Request = function(requestUri) {
+      // Store the request uri so tests can inspect it
+      Request._requestUri = requestUri;
+      return {
+        setHeader(header, value) {
+          if (header == "If-None-Match" && value == "bogusETag") {
+            ifNoneMatchSet = true;
+          }
+        },
+        get() {
+          this.response = response;
+          this.onComplete();
+        }
+      };
+    };
+
+    return Request;
+  };
+
+  let req = new mockResponse(response);
+  client._Request = req;
+  client.fetchProfile("bogusETag")
+    .then(
+      function(result) {
+        do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/profile");
+        do_check_eq(result.body.email, "someone@restmail.net");
+        do_check_eq(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+        do_check_true(ifNoneMatchSet);
         run_next_test();
       }
     );
 });
 
 add_test(function parseErrorResponse() {
   let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
   let response = {
@@ -157,16 +204,17 @@ add_test(function server401ResponseThenS
   let responses = [
     {
       status: 401,
       body: "{ \"code\": 401, \"errno\": 100, \"error\": \"Token expired\", \"message\": \"That token is too old\", \"reason\": \"Because security\" }",
     },
     {
       success: true,
       status: STATUS_SUCCESS,
+      headers: {},
       body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
     },
   ];
 
   let numRequests = 0;
   let numAuthHeaders = 0;
   // Like mockResponse but we want access to headers etc.
   client._Request = function(requestUri) {
@@ -182,18 +230,18 @@ add_test(function server401ResponseThenS
         ++numRequests;
         this.onComplete();
       }
     };
   }
 
   client.fetchProfile()
     .then(result => {
-      do_check_eq(result.avatar, "http://example.com/image.jpg");
-      do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a");
+      do_check_eq(result.body.avatar, "http://example.com/image.jpg");
+      do_check_eq(result.body.id, "0d5c1a89b8c54580b8e3e8adadae864a");
       // should have been exactly 2 requests and exactly 2 auth headers.
       do_check_eq(numRequests, 2);
       do_check_eq(numAuthHeaders, 2);
       // and we should have seen one token revoked.
       do_check_eq(numTokensRemoved, 1);
 
       run_next_test();
     }
@@ -313,36 +361,16 @@ add_test(function onCompleteRequestError
         do_check_eq(e.errno, ERRNO_NETWORK);
         do_check_eq(e.error, ERROR_NETWORK);
         do_check_eq(e.message, "Error: onComplete error");
         run_next_test();
       }
   );
 });
 
-add_test(function fetchProfileImage_successfulResponse() {
-  let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
-  let response = {
-    success: true,
-    status: STATUS_SUCCESS,
-    body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
-  };
-
-  client._Request = new mockResponse(response);
-  client.fetchProfileImage()
-    .then(
-      function(result) {
-        do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/avatar");
-        do_check_eq(result.avatar, "http://example.com/image.jpg");
-        do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a");
-        run_next_test();
-      }
-    );
-});
-
 add_test(function constructorTests() {
   validationHelper(undefined,
     "Error: Missing 'serverURL' configuration option");
 
   validationHelper({},
     "Error: Missing 'serverURL' configuration option");
 
   validationHelper({ serverURL: "badUrl" },
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js
-tail =
 skip-if = (toolkit == 'android' || appname == 'thunderbird')
 support-files =
   !/services/common/tests/unit/head_helpers.js
   !/services/common/tests/unit/head_http.js
 
 [test_accounts.js]
 [test_accounts_device_registration.js]
 [test_client.js]
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_appinfo.js ../../../common/tests/unit/head_helpers.js head_helpers.js head_http_server.js head_errorhandler_common.js
-tail =
 firefox-appdir = browser
 support-files =
   addon1-search.xml
   bootstrap1-search.xml
   fake_login_manager.js
   missing-sourceuri.xml
   missing-xpi-search.xml
   places_v10_from_v11.sqlite
--- a/storage/test/unit/xpcshell.ini
+++ b/storage/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_storage.js
-tail =
 support-files =
   corruptDB.sqlite
   fakeDB.sqlite
   locale_collation.txt
   vacuumParticipant.js
   vacuumParticipant.manifest
 
 [test_bug-365166.js]
--- a/taskcluster/ci/test/test-platforms.yml
+++ b/taskcluster/ci/test/test-platforms.yml
@@ -42,16 +42,17 @@ linux64/opt:
 
 # TODO: use 'pgo' and 'asan' labels here, instead of -pgo/opt
 linux64-pgo/opt:
     build-platform: linux64-pgo/opt
     test-sets:
         - common-tests
         - external-media-tests
         - web-platform-tests
+        - talos
 
 linux64-asan/opt:
     build-platform: linux64-asan/opt
     test-sets:
         - common-tests
 
 # Stylo builds only run a subset of tests for the moment. So give them
 # their own test set.
new file mode 100644
--- /dev/null
+++ b/taskcluster/docs/cron.rst
@@ -0,0 +1,48 @@
+Periodic Taskgraphs
+===================
+
+The cron functionality allows in-tree scheduling of task graphs that run
+periodically, instead of on a push.
+
+How It Works
+------------
+
+The `TaskCluster Hooks Service <https://tools.taskcluster.net/hooks>`_ has a
+hook configured for each repository supporting periodic task graphs.  The hook
+runs every 15 minutes, and the resulting task is referred to as a "cron task".
+That cron task runs `./mach taskgraph cron` in a checkout of the Gecko source
+tree.
+
+The mach subcommand reads ``.cron.yml``, then consults the current time
+(actually the time the cron task was created, rounded down to the nearest 15
+minutes) and creates tasks for any cron jobs scheduled at that time.
+
+Each cron job in ``.cron.yml`` specifies a ``job.using``, corresponding to a
+function responsible for creating TaskCluster tasks when the job runs.
+
+Decision Tasks
+..............
+
+For ``job.using`` "decision-task", tasks are created based on
+``.taskcluster.yml`` just like the decision tasks that result from a push to a
+repository.  They run with a distinct ``taskGroupId``, and are free to create
+additional tasks comprising a task graph.
+
+Scopes
+------
+
+The cron task runs with the sum of all cron job scopes for the given repo.  For
+example, for the "sequoia" project, the scope would be
+``assume:repo:hg.mozilla.org/projects/sequoia:cron:*``.  Each cron job creates
+tasks with scopes for that particular job, by name.  For example, the
+``check-frob`` cron job on that repo would run with
+``assume:repo:hg.mozilla.org/projects/sequoia:cron:check-frob``.
+
+.. important::
+
+    The individual cron scopes are a useful check to ensure that a job is not
+    accidentally doing something it should not, but cannot actually *prevent* a
+    job from using any of the scopes afforded to the cron task itself (the
+    ``..cron:*`` scope).  This is simply because the cron task runs arbitrary
+    code from the repo, and that code can be easily modified to create tasks
+    with any scopes that it posesses.
--- a/taskcluster/docs/index.rst
+++ b/taskcluster/docs/index.rst
@@ -21,10 +21,11 @@ check out the :doc:`how-to section <how-
 
 .. toctree::
 
     taskgraph
     loading
     transforms
     yaml-templates
     docker-images
+    cron
     how-tos
     reference
--- a/taskcluster/mach_commands.py
+++ b/taskcluster/mach_commands.py
@@ -123,19 +123,16 @@ class MachCommands(MachCommandBase):
                      required=True,
                      help='Reference (this is same as rev usually for hg)')
     @CommandArgument('--head-rev',
                      required=True,
                      help='Commit revision to use from head repository')
     @CommandArgument('--message',
                      required=True,
                      help='Commit message to be parsed. Example: "try: -b do -p all -u all"')
-    @CommandArgument('--revision-hash',
-                     required=True,
-                     help='Treeherder revision hash (long revision id) to attach results to')
     @CommandArgument('--project',
                      required=True,
                      help='Project to use for creating task graph. Example: --project=try')
     @CommandArgument('--pushlog-id',
                      dest='pushlog_id',
                      required=True,
                      default=0)
     @CommandArgument('--pushdate',
@@ -240,16 +237,52 @@ class MachCommands(MachCommandBase):
         import taskgraph.action
         try:
             self.setup_logging()
             return taskgraph.action.backfill(options['project'], options['job_id'])
         except Exception:
             traceback.print_exc()
             sys.exit(1)
 
+    @SubCommand('taskgraph', 'cron',
+                description="Run the cron task")
+    @CommandArgument('--base-repository',
+                     required=True,
+                     help='URL for "base" repository to clone')
+    @CommandArgument('--head-repository',
+                     required=True,
+                     help='URL for "head" repository to fetch')
+    @CommandArgument('--head-ref',
+                     required=True,
+                     help='Reference to fetch in head-repository (usually "default")')
+    @CommandArgument('--project',
+                     required=True,
+                     help='Project to use for creating tasks. Example: --project=mozilla-central')
+    @CommandArgument('--level',
+                     required=True,
+                     help='SCM level of this repository')
+    @CommandArgument('--force-run',
+                     required=False,
+                     help='If given, force this cronjob to run regardless of time, '
+                     'and run no others')
+    @CommandArgument('--no-create',
+                     required=False,
+                     action='store_true',
+                     help='Do not actually create tasks')
+    def taskgraph_cron(self, **options):
+        """Run the cron task; this task creates zero or more decision tasks.  It is run
+        from the hooks service on a regular basis."""
+        import taskgraph.cron
+        try:
+            self.setup_logging()
+            return taskgraph.cron.taskgraph_cron(options)
+        except Exception:
+            traceback.print_exc()
+            sys.exit(1)
+
     def setup_logging(self, quiet=False, verbose=True):
         """
         Set up Python logging for all loggers, sending results to stderr (so
         that command output can be redirected easily) and adding the typical
         mach timestamp.
         """
         # remove the old terminal handler
         old = self.log_manager.replace_terminal_handler(None)
--- a/taskcluster/taskgraph/create.py
+++ b/taskcluster/taskgraph/create.py
@@ -70,31 +70,31 @@ def create_tasks(taskgraph, label_to_tas
             task_def['schedulerId'] = scheduler_id
 
             # Wait for dependencies before submitting this.
             deps_fs = [fs[dep] for dep in task_def.get('dependencies', [])
                        if dep in fs]
             for f in futures.as_completed(deps_fs):
                 f.result()
 
-            fs[task_id] = e.submit(_create_task, session, task_id,
+            fs[task_id] = e.submit(create_task, session, task_id,
                                    taskid_to_label[task_id], task_def)
 
             # Schedule tasks as many times as task_duplicates indicates
             for i in range(1, attributes.get('task_duplicates', 1)):
                 # We use slugid() since we want a distinct task id
-                fs[task_id] = e.submit(_create_task, session, slugid(),
+                fs[task_id] = e.submit(create_task, session, slugid(),
                                        taskid_to_label[task_id], task_def)
 
         # Wait for all futures to complete.
         for f in futures.as_completed(fs.values()):
             f.result()
 
 
-def _create_task(session, task_id, label, task_def):
+def create_task(session, task_id, label, task_def):
     # create the task using 'http://taskcluster/queue', which is proxied to the queue service
     # with credentials appropriate to this job.
 
     # Resolve timestamps
     now = current_json_time(datetime_format=True)
     task_def = resolve_timestamps(now, task_def)
 
     logger.debug("Creating task with taskId {} for {}".format(task_id, label))
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/cron/__init__.py
@@ -0,0 +1,160 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import datetime
+import json
+import logging
+import os
+import traceback
+import requests
+import yaml
+
+from . import decision, schema
+from .util import (
+    match_utc,
+    calculate_head_rev
+)
+from ..create import create_task
+
+# Functions to handle each `job.type` in `.cron.yml`.  These are called with
+# the contents of the `job` property from `.cron.yml` and should return a
+# sequence of (taskId, task) tuples which will subsequently be fed to
+# createTask.
+JOB_TYPES = {
+    'decision-task': decision.run_decision_task,
+}
+
+GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..', '..'))
+logger = logging.getLogger(__name__)
+_session = None
+
+
+def get_session():
+    global _session
+    if not _session:
+        _session = requests.Session()
+    return _session
+
+
+def load_jobs():
+    with open(os.path.join(GECKO, '.cron.yml'), 'rb') as f:
+        cron_yml = yaml.load(f)
+    schema.validate(cron_yml)
+    return {j['name']: j for j in cron_yml['jobs']}
+
+
+def should_run(job, params):
+    if 'projects' in job:
+        if not any(p == params['project'] for p in job['projects']):
+            return False
+    if not any(match_utc(params, hour=sched.get('hour'), minute=sched.get('minute'))
+               for sched in job.get('when', [])):
+        return False
+    return True
+
+
+def run_job(job_name, job, params):
+    params['job_name'] = job_name
+
+    try:
+        job_type = job['job']['type']
+        if job_type in JOB_TYPES:
+            tasks = JOB_TYPES[job_type](job['job'], params)
+        else:
+            raise Exception("job type {} not recognized".format(job_type))
+        if params['no_create']:
+            for task_id, task in tasks:
+                logger.info("Not creating task {} (--no-create):\n".format(task_id) +
+                            json.dumps(task, sort_keys=True, indent=4, separators=(',', ': ')))
+        else:
+            for task_id, task in tasks:
+                create_task(get_session(), task_id, params['job_name'], task)
+
+    except Exception:
+        # report the exception, but don't fail the whole cron task, as that
+        # would leave other jobs un-run.  NOTE: we could report job failure to
+        # a responsible person here via tc-notify
+        traceback.print_exc()
+        logger.error("cron job {} run failed; continuing to next job".format(params['job_name']))
+
+
+def calculate_time(options):
+    if 'TASK_ID' not in os.environ:
+        # running in a development environment, so look for CRON_TIME or use
+        # the current time
+        if 'CRON_TIME' in os.environ:
+            logger.warning("setting params['time'] based on $CRON_TIME")
+            time = datetime.datetime.utcfromtimestamp(int(os.environ['CRON_TIME']))
+        else:
+            logger.warning("using current time for params['time']; try setting $CRON_TIME "
+                           "to a timestamp")
+            time = datetime.datetime.utcnow()
+    else:
+        # fetch this task from the queue
+        res = get_session().get('http://taskcluster/queue/v1/task/' + os.environ['TASK_ID'])
+        if res.status_code != 200:
+            try:
+                logger.error(res.json()['message'])
+            except:
+                logger.error(res.text)
+            res.raise_for_status()
+        # the task's `created` time is close to when the hook ran, although that
+        # may be some time ago if task execution was delayed
+        created = res.json()['created']
+        time = datetime.datetime.strptime(created, '%Y-%m-%dT%H:%M:%S.%fZ')
+
+    # round down to the nearest 15m
+    minute = time.minute - (time.minute % 15)
+    time = time.replace(minute=minute, second=0, microsecond=0)
+    logger.info("calculated cron schedule time is {}".format(time))
+    return time
+
+
+def taskgraph_cron(options):
+    params = {
+        # name of this cron job (set per job below)
+        'job_name': '..',
+
+        # repositories
+        'base_repository': options['base_repository'],
+        'head_repository': options['head_repository'],
+
+        # the symbolic ref this should run against (which happens to be what
+        # run-task checked out for us)
+        'head_ref': options['head_ref'],
+
+        # *calculated* head_rev; this is based on the current meaning of this
+        # reference in the working copy
+        'head_rev': calculate_head_rev(options),
+
+        # the project (short name for the repository) and its SCM level
+        'project': options['project'],
+        'level': options['level'],
+
+        # if true, tasks will not actually be created
+        'no_create': options['no_create'],
+
+        # the time that this cron task was created (as a UTC datetime object)
+        'time': calculate_time(options),
+    }
+
+    jobs = load_jobs()
+
+    if options['force_run']:
+        job_name = options['force_run']
+        logger.info("force-running cron job {}".format(job_name))
+        run_job(job_name, jobs[job_name], params)
+        return
+
+    for job_name, job in sorted(jobs.items()):
+        if should_run(job, params):
+            logger.info("running cron job {}".format(job_name))
+            run_job(job_name, job, params)
+        else:
+            logger.info("not running cron job {}".format(job_name))
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/cron/decision.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import pipes
+import yaml
+import re
+import os
+import slugid
+
+
+def run_decision_task(job, params):
+    arguments = []
+    if 'triggered-by' in job:
+        arguments.append('--triggered-by={}'.format(job['triggered-by']))
+    if 'target-tasks-method' in job:
+        arguments.append('--target-tasks-method={}'.format(job['target-tasks-method']))
+    return [
+        make_decision_task(
+            params,
+            symbol=job['treeherder-symbol'],
+            arguments=arguments),
+    ]
+
+
+def make_decision_task(params, symbol, arguments=[], head_rev=None):
+    """Generate a basic decision task, based on the root
+    .taskcluster.yml"""
+    with open('.taskcluster.yml') as f:
+        taskcluster_yml = f.read()
+
+    if not head_rev:
+        head_rev = params['head_rev']
+
+    # do a cheap and dirty job of the template substitution that mozilla-taskcluster
+    # does when it reads .taskcluster.yml
+    comment = '"no push -- cron task \'{job_name}\'"'.format(**params),
+    replacements = {
+        '\'{{{?now}}}?\'': "{'relative-datestamp': '0 seconds'}",
+        '{{{?owner}}}?': 'nobody@mozilla.org',
+        '{{#shellquote}}{{{comment}}}{{/shellquote}}': comment,
+        '{{{?source}}}?': params['head_repository'],
+        '{{{?url}}}?': params['head_repository'],
+        '{{{?project}}}?': params['project'],
+        '{{{?level}}}?': params['level'],
+        '{{{?revision}}}?': head_rev,
+        '\'{{#from_now}}([^{]*){{/from_now}}\'': "{'relative-datestamp': '\\1'}",
+        '{{{?pushdate}}}?': '0',
+        # treeherder ignores pushlog_id, so set it to -1
+        '{{{?pushlog_id}}}?': '-1',
+        # omitted as unnecessary
+        # {{#as_slugid}}..{{/as_slugid}}
+    }
+    for pattern, replacement in replacements.iteritems():
+        taskcluster_yml = re.sub(pattern, replacement, taskcluster_yml)
+
+    task = yaml.load(taskcluster_yml)['tasks'][0]['task']
+
+    # set some metadata
+    task['metadata']['name'] = 'Decision task for cron job ' + params['job_name']
+    cron_task_id = os.environ.get('TASK_ID', '<cron task id>')
+    descr_md = 'Created by a [cron task](https://tools.taskcluster.net/task-inspector/#{}/)'
+    task['metadata']['description'] = descr_md.format(cron_task_id)
+
+    th = task['extra']['treeherder']
+    th['groupSymbol'] = 'cron'
+    th['symbol'] = symbol
+
+    # add a scope based on the repository, with a cron:<job_name> suffix
+    match = re.match(r'https://(hg.mozilla.org)/(.*?)/?$', params['head_repository'])
+    if not match:
+        raise Exception('Unrecognized head_repository')
+    repo_scope = 'assume:repo:{}/{}:cron:{}'.format(
+        match.group(1), match.group(2), params['job_name'])
+    task.setdefault('scopes', []).append(repo_scope)
+
+    # append arguments, quoted, to the decision task command
+    shellcmd = task['payload']['command']
+    shellcmd[-1] = shellcmd[-1].rstrip('\n')  # strip yaml artifact
+    for arg in arguments:
+        shellcmd[-1] += ' ' + pipes.quote(arg)
+
+    task_id = slugid.nice()
+
+    # set taskGroupid = taskId, as expected of decision tasks by other systems.
+    # This creates a new taskGroup for this graph.
+    task['taskGroupId'] = task_id
+
+    # set the schedulerId based on the level
+    task['schedulerId'] = 'gecko-level-{}-cron'.format(params['level'])
+
+    return (task_id, task)
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/cron/schema.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+from voluptuous import Schema, Any, Required, All, MultipleInvalid
+
+
+def even_15_minutes(minutes):
+    if minutes % 15 != 0:
+        raise ValueError("minutes must be evenly divisible by 15")
+
+cron_yml_schema = Schema({
+    'jobs': [{
+        # Name of the crontask (must be unique)
+        Required('name'): basestring,
+
+        # what to run
+
+        # Description of the job to run, keyed by 'type'
+        Required('job'): Any({
+            Required('type'): 'decision-task',
+
+            # Treeherder symbol for the cron task
+            Required('treeherder-symbol'): basestring,
+
+            # --triggered-by './mach taskgraph decision' argument
+            'triggered-by': basestring,
+
+            # --target-tasks-method './mach taskgraph decision' argument
+            'target-tasks-method': basestring,
+        }),
+
+        # when to run it
+
+        # Optional set of projects on which this job should run; if omitted, this will
+        # run on all projects for which cron tasks are set up
+        'projects': [basestring],
+
+        # Array of times at which this task should run.  These *must* be a multiple of
+        # 15 minutes, the minimum scheduling interval.
+        'when': [{'hour': int, 'minute': All(int, even_15_minutes)}],
+    }],
+})
+
+
+def validate(cron_yml):
+    try:
+        cron_yml_schema(cron_yml)
+    except MultipleInvalid as exc:
+        msg = ["Invalid .cron.yml:"]
+        for error in exc.errors:
+            msg.append(str(error))
+        raise Exception('\n'.join(msg))
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/cron/util.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import subprocess
+
+
+def match_utc(params, hour=None, minute=None):
+    """ Return True if params['time'] matches the given hour and minute.
+    If hour is not specified, any hour will match.  If minute is not
+    specified, then every multiple of fifteen minutes will match.  Times
+    not an even multiple of fifteen minutes will result in an exception
+    (since they would never run)."""
+    if minute and minute % 15 != 0:
+        raise Exception("cron jobs only run on multiples of 15 minutes past the hour")
+    if hour and params['time'].hour != hour:
+        return False
+    if minute and params['time'].minute != minute:
+        return False
+    return True
+
+
+def calculate_head_rev(options):
+    return subprocess.check_output(['hg', 'log', '-r', options['head_ref'], '-T', '{node}'])
--- a/taskcluster/taskgraph/test/test_create.py
+++ b/taskcluster/taskgraph/test/test_create.py
@@ -17,21 +17,21 @@ from mozunit import main
 
 class TestCreate(unittest.TestCase):
 
     def setUp(self):
         self.old_task_id = os.environ.get('TASK_ID')
         if 'TASK_ID' in os.environ:
             del os.environ['TASK_ID']
         self.created_tasks = {}
-        self.old_create_task = create._create_task
-        create._create_task = self.fake_create_task
+        self.old_create_task = create.create_task
+        create.create_task = self.fake_create_task
 
     def tearDown(self):
-        create._create_task = self.old_create_task
+        create.create_task = self.old_create_task
         if self.old_task_id:
             os.environ['TASK_ID'] = self.old_task_id
         elif 'TASK_ID' in os.environ:
             del os.environ['TASK_ID']
 
     def fake_create_task(self, session, task_id, label, task_def):
         self.created_tasks[task_id] = task_def
 
--- a/testing/firefox-ui/tests/functional/security/manifest.ini
+++ b/testing/firefox-ui/tests/functional/security/manifest.ini
@@ -1,15 +1,17 @@
 [DEFAULT]
 tags = remote
 
 [test_dv_certificate.py]
+skip-if = true # Bug 1332298
 [test_enable_privilege.py]
 tags = local
 [test_ev_certificate.py]
+skip-if = true # Bug 1332298
 [test_mixed_content_page.py]
 [test_mixed_script_content_blocking.py]
 [test_no_certificate.py]
 tags = local
 [test_safe_browsing_initial_download.py]
 [test_safe_browsing_notification.py]
 [test_safe_browsing_warning_pages.py]
 [test_security_notification.py]
--- a/testing/firefox-ui/tests/puppeteer/manifest.ini
+++ b/testing/firefox-ui/tests/puppeteer/manifest.ini
@@ -13,12 +13,13 @@ tags = remote
 
 # UI tests
 [test_about_window.py]
 [test_menubar.py]
 [test_notifications.py]
 [test_page_info_window.py]
 [test_tabbar.py]
 [test_toolbars.py]
+skip-if = true # Bug 1332298
 tags = remote
 [test_update_wizard.py]
 tags = remote
 [test_windows.py]
--- a/testing/mochitest/runtests.py
+++ b/testing/mochitest/runtests.py
@@ -2235,20 +2235,17 @@ toolbar#nav-bar {
             # Leak checking was broken in mochitest unnoticed for a length of time. During
             # this time, several leaks slipped through. The leak checking was fixed by bug
             # 1325148, but it couldn't land until all the regressions were also fixed or
             # backed out. Rather than waiting and risking new regressions, in the meantime
             # this code will selectively disable leak checking on flavors/directories where
             # known regressions exist. At least this way we can prevent further damage while
             # they get fixed.
 
-            info = mozinfo.info
-            skip_leak_conditions = [
-                (info['debug'] and options.flavor == 'plain' and d == 'toolkit/components/prompts/test' and info['os'] == 'mac', 'bug 1325275'),  # noqa
-            ]
+            skip_leak_conditions = []
 
             for condition, reason in skip_leak_conditions:
                 if condition:
                     self.log.warning('WARNING | disabling leakcheck due to {}'.format(reason))
                     self.disable_leak_checking = True
                     break
             else:
                 self.disable_leak_checking = False
--- a/testing/modules/tests/xpcshell/xpcshell.ini
+++ b/testing/modules/tests/xpcshell/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android' || toolkit == 'gonk'
 
 [test_assert.js]
 [test_mockRegistrar.js]
 [test_structuredlog.js]
--- a/testing/talos/talos/xtalos/xperf_whitelist.json
+++ b/testing/talos/talos/xtalos/xperf_whitelist.json
@@ -2,16 +2,17 @@
  "C:\\$Mft": {"ignore": true},
  "C:\\$Extend\\$UsnJrnl:$J": {"ignore": true},
  "C:\\Windows\\Prefetch\\{prefetch}.pf": {"ignore": true},
  "C:\\$Secure": {"ignore": true},
  "C:\\$logfile": {"ignore": true},
  "{firefox}\\omni.ja": {"mincount": 0, "maxcount": 46, "minbytes": 0, "maxbytes": 3014656},
  "{firefox}\\browser\\omni.ja": {"mincount": 0, "maxcount": 28, "minbytes": 0, "maxbytes": 1835008},
  "{firefox}\\browser\\features\\aushelper@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
+ "{firefox}\\browser\\features\\disableSHA1rollout@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\e10srollout@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\flyweb@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\formautofill@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\loop@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\firefox@getpocket.com.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\presentation@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\webcompat@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\shield-recipe-client@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
--- a/testing/xpcshell/example/unit/xpcshell.ini
+++ b/testing/xpcshell/example/unit/xpcshell.ini
@@ -1,15 +1,14 @@
 ; 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/.
 
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'gonk'
 support-files =
   subdir/file.txt
   file.txt
   import_module.jsm
   import_sub_module.jsm
   load_subscript.js
   location_load.js
--- a/testing/xpcshell/head.js
+++ b/testing/xpcshell/head.js
@@ -572,19 +572,16 @@ function _execute_test() {
       _testLogger.error(message, extra);
     }
   }
 
   if (coverageCollector != null) {
     coverageCollector.finalize();
   }
 
-  // _TAIL_FILES is dynamically defined by <runxpcshelltests.py>.
-  _load_files(_TAIL_FILES);
-
   // Execute all of our cleanup functions.
   let reportCleanupError = function(ex) {
     let stack, filename;
     if (ex && typeof ex == "object" && "stack" in ex) {
       stack = ex.stack;
     } else {
       stack = Components.stack.caller;
     }
@@ -1259,17 +1256,16 @@ function do_load_child_test_harness()
   do_load_child_test_harness.alreadyRun = 1;
 
   _XPCSHELL_PROCESS = "parent";
 
   let command =
         "const _HEAD_JS_PATH=" + uneval(_HEAD_JS_PATH) + "; "
       + "const _HEAD_FILES=" + uneval(_HEAD_FILES) + "; "
       + "const _MOZINFO_JS_PATH=" + uneval(_MOZINFO_JS_PATH) + "; "
-      + "const _TAIL_FILES=" + uneval(_TAIL_FILES) + "; "
       + "const _TEST_NAME=" + uneval(_TEST_NAME) + "; "
       // We'll need more magic to get the debugger working in the child
       + "const _JSDEBUGGER_PORT=0; "
       + "const _XPCSHELL_PROCESS='child';";
 
   if (typeof _JSCOV_DIR === 'string') {
     command += " const _JSCOV_DIR=" + uneval(_JSCOV_DIR) + ";";
   }
--- a/testing/xpcshell/remotexpcshelltests.py
+++ b/testing/xpcshell/remotexpcshelltests.py
@@ -82,21 +82,20 @@ class RemoteXPCShellTestThread(xpcshell.
         os.remove(local)
         return mozInfoJSPath
 
     def logCommand(self, name, completeCmd, testdir):
         self.log.info("%s | full command: %r" % (name, completeCmd))
         self.log.info("%s | current directory: %r" % (name, self.remoteHere))
         self.log.info("%s | environment: %s" % (name, self.env))
 
-    def getHeadAndTailFiles(self, test):
+    def getHeadFiles(self, test):
         """Override parent method to find files on remote device.
 
-        Obtains lists of head- and tail files.  Returns a tuple containing
-        a list of head files and a list of tail files.
+        Obtains lists of head- files.  Returns a list of head files.
         """
         def sanitize_list(s, kind):
             for f in s.strip().split(' '):
                 f = f.strip()
                 if len(f) < 1:
                     continue
 
                 path = remoteJoin(self.remoteHere, f)
@@ -104,19 +103,17 @@ class RemoteXPCShellTestThread(xpcshell.
                 # skip check for file existence: the convenience of discovering
                 # a missing file does not justify the time cost of the round trip
                 # to the device
                 yield path
 
         self.remoteHere = self.remoteForLocal(test['here'])
 
         headlist = test.get('head', '')
-        taillist = test.get('tail', '')
-        return (list(sanitize_list(headlist, 'head')),
-                list(sanitize_list(taillist, 'tail')))
+        return list(sanitize_list(headlist, 'head'))
 
     def buildXpcsCmd(self):
         # change base class' paths to remote paths and use base class to build command
         self.xpcshell = remoteJoin(self.remoteBinDir, "xpcw")
         self.headJSPath = remoteJoin(self.remoteScriptsDir, 'head.js')
         self.httpdJSPath = remoteJoin(self.remoteComponentsDir, 'httpd.js')
         self.httpdManifest = remoteJoin(self.remoteComponentsDir, 'httpd.manifest')
         self.testingModulesDir = self.remoteModulesDir
--- a/testing/xpcshell/runxpcshelltests.py
+++ b/testing/xpcshell/runxpcshelltests.py
@@ -371,40 +371,36 @@ class XPCShellTestThread(Thread):
         return profileDir
 
     def setupMozinfoJS(self):
         mozInfoJSPath = os.path.join(self.profileDir, 'mozinfo.json')
         mozInfoJSPath = mozInfoJSPath.replace('\\', '\\\\')
         mozinfo.output_to_file(mozInfoJSPath)
         return mozInfoJSPath
 
-    def buildCmdHead(self, headfiles, tailfiles, xpcscmd):
+    def buildCmdHead(self, headfiles, xpcscmd):
         """
-          Build the command line arguments for the head and tail files,
+          Build the command line arguments for the head files,
           along with the address of the webserver which some tests require.
 
           On a remote system, this is overloaded to resolve quoting issues over a secondary command line.
         """
         cmdH = ", ".join(['"' + f.replace('\\', '/') + '"'
                        for f in headfiles])
-        cmdT = ", ".join(['"' + f.replace('\\', '/') + '"'
-                       for f in tailfiles])
 
         dbgport = 0 if self.jsDebuggerInfo is None else self.jsDebuggerInfo.port
 
         return xpcscmd + \
                 ['-e', 'const _SERVER_ADDR = "localhost"',
                  '-e', 'const _HEAD_FILES = [%s];' % cmdH,
-                 '-e', 'const _TAIL_FILES = [%s];' % cmdT,
                  '-e', 'const _JSDEBUGGER_PORT = %d;' % dbgport,
                 ]
 
-    def getHeadAndTailFiles(self, test):
-        """Obtain lists of head- and tail files.  Returns a tuple
-        containing a list of head files and a list of tail files.
+    def getHeadFiles(self, test):
+        """Obtain lists of head- files.  Returns a list of head files.
         """
         def sanitize_list(s, kind):
             for f in s.strip().split(' '):
                 f = f.strip()
                 if len(f) < 1:
                     continue
 
                 path = os.path.normpath(os.path.join(test['here'], f))
@@ -412,23 +408,21 @@ class XPCShellTestThread(Thread):
                     raise Exception('%s file does not exist: %s' % (kind, path))
 
                 if not os.path.isfile(path):
                     raise Exception('%s file is not a file: %s' % (kind, path))
 
                 yield path
 
         headlist = test.get('head', '')
-        taillist = test.get('tail', '')
-        return (list(sanitize_list(headlist, 'head')),
-                list(sanitize_list(taillist, 'tail')))
+        return list(sanitize_list(headlist, 'head'))
 
     def buildXpcsCmd(self):
         """
-          Load the root head.js file as the first file in our test path, before other head, test, and tail files.
+          Load the root head.js file as the first file in our test path, before other head, and test files.
           On a remote system, we overload this to add additional command line arguments, so this gets overloaded.
         """
         # - NOTE: if you rename/add any of the constants set here, update
         #   do_load_child_test_harness() in head.js
         if not self.appPath:
             self.appPath = self.xrePath
 
         self.xpcsCmd = [
@@ -618,18 +612,18 @@ class XPCShellTestThread(Thread):
 
         # Create a profile and a temp dir that the JS harness can stick
         # a profile and temporary data in
         self.profileDir = self.setupProfileDir()
         self.tempDir = self.setupTempDir()
         self.mozInfoJSPath = self.setupMozinfoJS()
 
         self.buildXpcsCmd()
-        head_files, tail_files = self.getHeadAndTailFiles(self.test_object)
-        cmdH = self.buildCmdHead(head_files, tail_files, self.xpcsCmd)
+        head_files = self.getHeadFiles(self.test_object)
+        cmdH = self.buildCmdHead(head_files, self.xpcsCmd)
 
         # The test file will have to be loaded after the head files.
         cmdT = self.buildCmdTestFile(path)
 
         args = self.xpcsRunArgs[:]
         if 'debug' in self.test_object:
             args.insert(0, '-d')
 
--- a/testing/xpcshell/selftest.py
+++ b/testing/xpcshell/selftest.py
@@ -1042,33 +1042,16 @@ add_test({
             # The actual return value is never checked because we raise.
             self.assertTestResult(True)
         except Exception, ex:
             raised = True
             self.assertEquals(ex.message[0:9], "head file")
 
         self.assertTrue(raised)
 
-    def testMissingTailFile(self):
-        """
-        Ensure that missing tail file results in fatal error.
-        """
-        self.writeFile("test_basic.js", SIMPLE_PASSING_TEST)
-        self.writeManifest([("test_basic.js", "tail = missing.js")])
-
-        raised = False
-
-        try:
-            self.assertTestResult(True)
-        except Exception, ex:
-            raised = True
-            self.assertEquals(ex.message[0:9], "tail file")
-
-        self.assertTrue(raised)
-
     def testRandomExecution(self):
         """
         Check that random execution doesn't break.
         """
         manifest = []
         for i in range(0, 10):
             filename = "test_pass_%d.js" % i
             self.writeFile(filename, SIMPLE_PASSING_TEST)
--- a/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.ini
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head=head.js
-tail=
 skip-if = toolkit == 'android'
 
 [test_AsyncShutdown.js]
 [test_AsyncShutdown_leave_uncaught.js]
 [test_converters.js]
--- a/toolkit/components/autocomplete/tests/unit/xpcshell.ini
+++ b/toolkit/components/autocomplete/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_autocomplete.js
-tail = 
 
 [test_330578.js]
 [test_378079.js]
 [test_393191.js]
 [test_440866.js]
 [test_463023.js]
 [test_660156.js]
 [test_autocomplete_multiple.js]
--- a/toolkit/components/captivedetect/test/unit/xpcshell.ini
+++ b/toolkit/components/captivedetect/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_setprefs.js
-tail =
 
 [test_captive_portal_not_found.js]
 [test_captive_portal_not_found_404.js]
 [test_captive_portal_found.js]
 [test_captive_portal_found_303.js]
 [test_abort.js]
 [test_abort_during_user_login.js]
 [test_user_cancel.js]
--- a/toolkit/components/commandlines/test/unit/xpcshell.ini
+++ b/toolkit/components/commandlines/test/unit/xpcshell.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 support-files =
   data/test_bug410156.desktop
   data/test_bug410156.url
 
 [test_classinfo.js]
 [test_bug666224.js]
--- a/toolkit/components/commandlines/test/unit_unix/xpcshell.ini
+++ b/toolkit/components/commandlines/test/unit_unix/xpcshell.ini
@@ -1,9 +1,8 @@
 [DEFAULT]
 head = 
-tail = 
 skip-if = toolkit == 'android'
 support-files =
   !/toolkit/components/commandlines/test/unit/data/test_bug410156.desktop
   !/toolkit/components/commandlines/test/unit/data/test_bug410156.url
 
 [test_bug410156.js]
--- a/toolkit/components/commandlines/test/unit_win/xpcshell.ini
+++ b/toolkit/components/commandlines/test/unit_win/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head = 
-tail =
 support-files =
   !/toolkit/components/commandlines/test/unit/data/test_bug410156.desktop
   !/toolkit/components/commandlines/test/unit/data/test_bug410156.url
 
 [test_bug410156.js]
--- a/toolkit/components/contentprefs/tests/unit/xpcshell.ini
+++ b/toolkit/components/contentprefs/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head_contentPrefs.js
-tail =
 
 [test_bug248970.js]
 [test_bug503971.js]
 [test_bug679784.js]
 [test_contentPrefs.js]
 [test_contentPrefsCache.js]
 [test_getPrefAsync.js]
 [test_stringGroups.js]
--- a/toolkit/components/contentprefs/tests/unit_cps2/xpcshell.ini
+++ b/toolkit/components/contentprefs/tests/unit_cps2/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 skip-if = toolkit == 'android'
 support-files = AsyncRunner.jsm
 
 [test_service.js]
 [test_setGet.js]
 [test_getSubdomains.js]
 [test_remove.js]
 [test_removeByDomain.js]
--- a/toolkit/components/crashes/tests/xpcshell/xpcshell.ini
+++ b/toolkit/components/crashes/tests/xpcshell/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head =
-tail =
 skip-if = toolkit == 'android'
 
 [test_crash_manager.js]
 [test_crash_service.js]
 [test_crash_store.js]
--- a/toolkit/components/crashmonitor/test/unit/xpcshell.ini
+++ b/toolkit/components/crashmonitor/test/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 skip-if = toolkit == 'android'
 
 [test_init.js]
 [test_valid_file.js]
 [test_invalid_file.js]
 [test_invalid_json.js]
 [test_missing_file.js]
 [test_register.js]
--- a/toolkit/components/ctypes/tests/unit/xpcshell.ini
+++ b/toolkit/components/ctypes/tests/unit/xpcshell.ini
@@ -1,11 +1,10 @@
 [DEFAULT]
 head = head.js
-tail =
 skip-if = toolkit == 'android'
 
 [test_errno.js]
 
 [test_finalizer.js]
 [test_finalizer_shouldfail.js]
 [test_finalizer_shouldaccept.js]
 [test_jsctypes.js]
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -898,19 +898,19 @@ defineLazyGetter(ExtensionPageContextChi
 
   if (this.viewType == "background") {
     apiManager.global.initializeBackgroundPage(this.contentWindow);
   }
 
   return childManager;
 });
 
-class DevtoolsContextChild extends ExtensionBaseContextChild {
+class DevToolsContextChild extends ExtensionBaseContextChild {
   /**
-   * This DevtoolsContextChild represents a devtools-related addon execution
+   * This DevToolsContextChild represents a devtools-related addon execution
    * environment that has access to the devtools API namespace and to the same subset
    * of APIs available in a content script execution environment.
    *
    * @param {BrowserExtensionContent} extension This context's owner.
    * @param {object} params
    * @param {nsIDOMWindow} params.contentWindow The window where the addon runs.
    * @param {string} params.viewType One of "devtools_page" or "devtools_panel".
    * @param {object} [params.devtoolsToolboxInfo] This devtools toolbox's information,
@@ -925,17 +925,17 @@ class DevtoolsContextChild extends Exten
   }
 
   unload() {
     super.unload();
     this.extension.devtoolsViews.delete(this);
   }
 }
 
-defineLazyGetter(DevtoolsContextChild.prototype, "childManager", function() {
+defineLazyGetter(DevToolsContextChild.prototype, "childManager", function() {
   let localApis = {};
   devtoolsAPIManager.generateAPIs(this, localApis);
 
   let childManager = new ChildAPIManager(this, this.messageManager, localApis, {
     envType: "devtools_parent",
     viewType: this.viewType,
     url: this.uri.spec,
     incognito: this.incognito,
@@ -1086,17 +1086,17 @@ ExtensionChild = {
                           .QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIContentFrameMessageManager);
 
     let {viewType, tabId, devtoolsToolboxInfo} = this.contentGlobals.get(mm).ensureInitialized();
 
     let uri = contentWindow.document.documentURIObject;
 
     if (devtoolsToolboxInfo) {
-      context = new DevtoolsContextChild(extension, {
+      context = new DevToolsContextChild(extension, {
         viewType, contentWindow, uri, tabId, devtoolsToolboxInfo,
       });
     } else {
       context = new ExtensionPageContextChild(extension, {viewType, contentWindow, uri, tabId});
     }
 
     this.extensionContexts.set(windowId, context);
   },
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -11,52 +11,62 @@
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 /* exported ExtensionParent */
 
 this.EXPORTED_SYMBOLS = ["ExtensionParent"];
 
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils",
                                   "resource:///modules/E10SUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NativeApp",
                                   "resource://gre/modules/NativeMessaging.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 
 Cu.import("resource://gre/modules/ExtensionCommon.jsm");
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 
 var {
   BaseContext,
   SchemaAPIManager,
 } = ExtensionCommon;
 
 var {
   MessageManagerProxy,
   SpreadArgs,
   defineLazyGetter,
   findPathInObject,
+  promiseDocumentLoaded,
+  promiseEvent,
+  promiseObserved,
 } = ExtensionUtils;
 
 const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
 const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
 const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
 
+const XUL_URL = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + encodeURI(
+  `<?xml version="1.0"?>
+  <window id="documentElement"/>`);
+
 let schemaURLs = new Set();
 
 if (!AppConstants.RELEASE_OR_BETA) {
   schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
 }
 
 let GlobalManager;
 let ParentAPIManager;
@@ -217,32 +227,32 @@ GlobalManager = {
     this.extensionMap.delete(extension.id);
 
     if (this.extensionMap.size == 0 && this.initialized) {
       apiManager.off("extension-browser-inserted", this._onExtensionBrowser);
       this.initialized = false;
     }
   },
 
-  _onExtensionBrowser(type, browser) {
+  _onExtensionBrowser(type, browser, additionalData = {}) {
     browser.messageManager.loadFrameScript(`data:,
       Components.utils.import("resource://gre/modules/ExtensionContent.jsm");
       ExtensionContent.init(this);
       addEventListener("unload", function() {
         ExtensionContent.uninit(this);
       });
     `, false);
 
     let viewType = browser.getAttribute("webextension-view-type");
     if (viewType) {
       let data = {viewType};
 
       let {getBrowserInfo} = apiManager.global;
       if (getBrowserInfo) {
-        Object.assign(data, getBrowserInfo(browser));
+        Object.assign(data, getBrowserInfo(browser), additionalData);
       }
 
       browser.messageManager.sendAsyncMessage("Extension:InitExtensionView",
                                               data);
     }
   },
 
   getExtension(extensionId) {
@@ -368,16 +378,60 @@ class ExtensionPageContextParent extends
   }
 
   shutdown() {
     apiManager.emit("page-shutdown", this);
     super.shutdown();
   }
 }
 
+/**
+ * The parent side of proxied API context for devtools extension page, such as a
+ * devtools pages and panels running in ExtensionChild.jsm.
+ */
+class DevToolsExtensionPageContextParent extends ExtensionPageContextParent {
+  set devToolsToolbox(toolbox) {
+    if (this._devToolsToolbox) {
+      throw new Error("Cannot set the context DevTools toolbox twice");
+    }
+
+    this._devToolsToolbox = toolbox;
+
+    return toolbox;
+  }
+
+  get devToolsToolbox() {
+    return this._devToolsToolbox;
+  }
+
+  set devToolsTarget(contextDevToolsTarget) {
+    if (this._devToolsTarget) {
+      throw new Error("Cannot set the context DevTools target twice");
+    }
+
+    this._devToolsTarget = contextDevToolsTarget;
+
+    return contextDevToolsTarget;
+  }
+
+  get devToolsTarget() {
+    return this._devToolsTarget;
+  }
+
+  shutdown() {
+    if (!this._devToolsTarget) {
+      throw new Error("no DevTools target is set during DevTools Context shutdown");
+    }
+
+    this._devToolsTarget.destroy();
+    this._devToolsTarget = null;
+    this._devToolsToolbox = null;
+  }
+}
+
 ParentAPIManager = {
   proxyContexts: new Map(),
 
   init() {
     Services.obs.addObserver(this, "message-manager-close", false);
 
     Services.mm.addMessageListener("API:CreateProxyContext", this);
     Services.mm.addMessageListener("API:CloseProxyContext", this, true);
@@ -463,17 +517,21 @@ ParentAPIManager = {
           extension.parentMessageManager = processMessageManager;
         }
       }
 
       if (processMessageManager !== extension.parentMessageManager) {
         throw new Error("Attempt to create privileged extension parent from incorrect child process");
       }
 
-      context = new ExtensionPageContextParent(envType, extension, data, target);
+      if (envType == "addon_parent") {
+        context = new ExtensionPageContextParent(envType, extension, data, target);
+      } else if (envType == "devtools_parent") {
+        context = new DevToolsExtensionPageContextParent(envType, extension, data, target);
+      }
     } else if (envType == "content_parent") {
       context = new ContentScriptContextParent(envType, extension, data, target, principal);
     } else {
       throw new Error(`Invalid WebExtension context envType: ${envType}`);
     }
     this.proxyContexts.set(childId, context);
   },
 
@@ -572,14 +630,254 @@ ParentAPIManager = {
       throw new Error("WebExtension context not found!");
     }
     return context;
   },
 };
 
 ParentAPIManager.init();
 
+/**
+ * This is a base class used by the ext-backgroundPage and ext-devtools API implementations
+ * to inherits the shared boilerplate code needed to create a parent document for the hidden
+ * extension pages (e.g. the background page, the devtools page) in the BackgroundPage and
+ * DevToolsPage classes.
+ *
+ * @param {Extension} extension
+ *   the Extension which owns the hidden extension page created (used to decide
+ *   if the hidden extension page parent doc is going to be a windowlessBrowser or
+ *   a visible XUL window)
+ * @param {string} viewType
+ *  the viewType of the WebExtension page that is going to be loaded
+ *  in the created browser element (e.g. "background" or "devtools_page").
+ *
+ */
+class HiddenExtensionPage {
+  constructor(extension, viewType) {
+    if (!extension || !viewType) {
+      throw new Error("extension and viewType parameters are mandatory");
+    }
+    this.extension = extension;
+    this.viewType = viewType;
+    this.parentWindow = null;
+    this.windowlessBrowser = null;
+    this.browser = null;
+  }
+
+  /**
+   * Destroy the created parent document.
+   */
+  shutdown() {
+    if (this.unloaded) {
+      throw new Error("Unable to shutdown an unloaded HiddenExtensionPage instance");
+    }
+
+    this.unloaded = true;
+
+    if (this.browser) {
+      this.browser.remove();
+      this.browser = null;
+    }
+
+    // Navigate away from the background page to invalidate any
+    // setTimeouts or other callbacks.
+    if (this.webNav) {
+      this.webNav.loadURI("about:blank", 0, null, null, null);
+      this.webNav = null;
+    }
+
+    if (this.parentWindow) {
+      this.parentWindow.close();
+      this.parentWindow = null;
+    }
+
+    if (this.windowlessBrowser) {
+      this.windowlessBrowser.loadURI("about:blank", 0, null, null, null);
+      this.windowlessBrowser.close();
+      this.windowlessBrowser = null;
+    }
+  }
+
+  /**
+   * Creates the browser XUL element that will contain the WebExtension Page.
+   *
+   * @returns {Promise<XULElement>}
+   *   a Promise which resolves to the newly created browser XUL element.
+   */
+  createBrowserElement() {
+    if (this.browser) {
+      throw new Error("createBrowserElement called twice");
+    }
+
+    let waitForParentDocument;
+    if (this.extension.remote) {
+      waitForParentDocument = this.createWindowedBrowser();
+    } else {
+      waitForParentDocument = this.createWindowlessBrowser();
+    }
+
+    return waitForParentDocument.then(chromeDoc => {
+      const browser = this.browser = chromeDoc.createElement("browser");
+      browser.setAttribute("type", "content");
+      browser.setAttribute("disableglobalhistory", "true");
+      browser.setAttribute("webextension-view-type", this.viewType);
+
+      let awaitFrameLoader = Promise.resolve();
+
+      if (this.extension.remote) {
+        browser.setAttribute("remote", "true");
+        browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+        awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
+      }
+
+      chromeDoc.documentElement.appendChild(browser);
+      return awaitFrameLoader.then(() => browser);
+    });
+  }
+
+  /**
+   * Private helper that create a XULDocument in a windowless browser.
+   *
+   * An hidden extension page (e.g. a background page or devtools page) is usually
+   * loaded into a windowless browser, with no on-screen representation or graphical
+   * display abilities.
+   *
+   * This currently does not support remote browsers, and therefore cannot
+   * be used with out-of-process extensions.
+   *
+   * @returns {Promise<XULDocument>}
+   *   a promise which resolves to the newly created XULDocument.
+   */
+  createWindowlessBrowser() {
+    return Task.spawn(function* () {
+      // The invisible page is currently wrapped in a XUL window to fix an issue
+      // with using the canvas API from a background page (See Bug 1274775).
+      let windowlessBrowser = Services.appShell.createWindowlessBrowser(true);
+      this.windowlessBrowser = windowlessBrowser;
+
+      // The windowless browser is a thin wrapper around a docShell that keeps
+      // its related resources alive. It implements nsIWebNavigation and
+      // forwards its methods to the underlying docShell, but cannot act as a
+      // docShell itself. Calling `getInterface(nsIDocShell)` gives us the
+      // underlying docShell, and `QueryInterface(nsIWebNavigation)` gives us
+      // access to the webNav methods that are already available on the
+      // windowless browser, but contrary to appearances, they are not the same
+      // object.
+      let chromeShell = windowlessBrowser.QueryInterface(Ci.nsIInterfaceRequestor)
+                                         .getInterface(Ci.nsIDocShell)
+                                         .QueryInterface(Ci.nsIWebNavigation);
+
+      yield this.initParentWindow(chromeShell);
+
+      return promiseDocumentLoaded(windowlessBrowser.document);
+    }.bind(this));
+  }
+
+  /**
+   * Private helper that create a XULDocument in a visible dialog window.
+   *
+   * Using this helper, the extension page is loaded into a visible dialog window.
+   * Only to be used for debugging, and in temporary, test-only use for
+   * out-of-process extensions.
+   *
+   * @returns {Promise<XULDocument>}
+   *   a promise which resolves to the newly created XULDocument.
+   */
+  createWindowedBrowser() {
+    return Task.spawn(function* () {
+      let window = Services.ww.openWindow(null, "about:blank", "_blank",
+                                          "chrome,alwaysLowered,dialog", null);
+
+      this.parentWindow = window;
+
+      let chromeShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
+                              .getInterface(Ci.nsIDocShell)
+                              .QueryInterface(Ci.nsIWebNavigation);
+
+
+      yield this.initParentWindow(chromeShell);
+
+      window.minimize();
+
+      return promiseDocumentLoaded(window.document);
+    }.bind(this));
+  }
+
+  /**
+   * Private helper that initialize the created parent document.
+   *
+   * @param {nsIDocShell} chromeShell
+   *   the docShell related to initialize.
+   *
+   * @returns {Promise<nsIXULDocument>}
+   *   the initialized parent chrome document.
+   */
+  initParentWindow(chromeShell) {
+    if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
+      let attrs = chromeShell.getOriginAttributes();
+      attrs.privateBrowsingId = 1;
+      chromeShell.setOriginAttributes(attrs);
+    }
+
+    let system = Services.scriptSecurityManager.getSystemPrincipal();
+    chromeShell.createAboutBlankContentViewer(system);
+    chromeShell.useGlobalHistory = false;
+    chromeShell.loadURI(XUL_URL, 0, null, null, null);
+
+    return promiseObserved("chrome-document-global-created",
+                           win => win.document == chromeShell.document);
+  }
+}
+
+function promiseExtensionViewLoaded(browser) {
+  return new Promise(resolve => {
+    browser.messageManager.addMessageListener("Extension:ExtensionViewLoaded", function onLoad() {
+      browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad);
+      resolve();
+    });
+  });
+}
+
+/**
+ * This helper is used to subscribe a listener (e.g. in the ext-devtools API implementation)
+ * to be called for every ExtensionProxyContext created for an extension page given
+ * its related extension, viewType and browser element (both the top level context and any context
+ * created for the extension urls running into its iframe descendants).
+ *
+ * @param {object} params.extension
+ *   the Extension on which we are going to listen for the newly created ExtensionProxyContext.
+ * @param {string} params.viewType
+ *  the viewType of the WebExtension page that we are watching (e.g. "background" or "devtools_page").
+ * @param {XULElement} params.browser
+ *  the browser element of the WebExtension page that we are watching.
+ *
+ * @param {Function} onExtensionProxyContextLoaded
+ *  the callback that is called when a new context has been loaded (as `callback(context)`);
+ *
+ * @returns {Function}
+ *   Unsubscribe the listener.
+ */
+function watchExtensionProxyContextLoad({extension, viewType, browser}, onExtensionProxyContextLoaded) {
+  if (typeof onExtensionProxyContextLoaded !== "function") {
+    throw new Error("Missing onExtensionProxyContextLoaded handler");
+  }
+
+  const listener = (event, context) => {
+    if (context.viewType == viewType && context.xulBrowser == browser) {
+      onExtensionProxyContextLoaded(context);
+    }
+  };
+
+  extension.on("extension-proxy-context-load", listener);
+
+  return () => {
+    extension.off("extension-proxy-context-load", listener);
+  };
+}
 
 const ExtensionParent = {
   GlobalManager,
+  HiddenExtensionPage,
   ParentAPIManager,
   apiManager,
+  promiseExtensionViewLoaded,
+  watchExtensionProxyContextLoad,
 };
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -2,234 +2,86 @@
 
 var {interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils",
-                                  "resource:///modules/E10SUtils.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
-                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
-Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+Cu.import("resource://gre/modules/ExtensionParent.jsm");
 const {
-  promiseDocumentLoaded,
-  promiseEvent,
-  promiseObserved,
-} = ExtensionUtils;
-
-const XUL_URL = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + encodeURI(
-  `<?xml version="1.0"?>
-  <window id="documentElement"/>`);
+  HiddenExtensionPage,
+  promiseExtensionViewLoaded,
+} = ExtensionParent;
 
 // WeakMap[Extension -> BackgroundPage]
 var backgroundPagesMap = new WeakMap();
 
 // Responsible for the background_page section of the manifest.
-class BackgroundPageBase {
-  constructor(options, extension) {
-    this.extension = extension;
+class BackgroundPage extends HiddenExtensionPage {
+  constructor(extension, options) {
+    super(extension, "background");
+
     this.page = options.page || null;
     this.isGenerated = !!options.scripts;
     this.webNav = null;
+
+    if (this.page) {
+      this.url = this.extension.baseURI.resolve(this.page);
+    } else if (this.isGenerated) {
+      this.url = this.extension.baseURI.resolve("_generated_background_page.html");
+    }
+
+    if (!this.extension.isExtensionURL(this.url)) {
+      this.extension.manifestError("Background page must be a file within the extension");
+      this.url = this.extension.baseURI.resolve("_blank.html");
+    }
   }
 
   build() {
     return Task.spawn(function* () {
-      let url;
-      if (this.page) {
-        url = this.extension.baseURI.resolve(this.page);
-      } else if (this.isGenerated) {
-        url = this.extension.baseURI.resolve("_generated_background_page.html");
-      }
+      yield this.createBrowserElement();
 
-      if (!this.extension.isExtensionURL(url)) {
-        this.extension.manifestError("Background page must be a file within the extension");
-        url = this.extension.baseURI.resolve("_blank.html");
-      }
+      extensions.emit("extension-browser-inserted", this.browser);
 
-      let chromeDoc = yield this.getParentDocument();
-
-      let browser = chromeDoc.createElement("browser");
-      browser.setAttribute("type", "content");
-      browser.setAttribute("disableglobalhistory", "true");
-      browser.setAttribute("webextension-view-type", "background");
+      this.browser.loadURI(this.url);
 
-      let awaitFrameLoader;
-      if (this.extension.remote) {
-        browser.setAttribute("remote", "true");
-        browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
-        awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
-      }
-
-      chromeDoc.documentElement.appendChild(browser);
-      yield awaitFrameLoader;
-
-      this.browser = browser;
-
-      extensions.emit("extension-browser-inserted", browser);
+      yield promiseExtensionViewLoaded(this.browser);
 
-      browser.loadURI(url);
-
-      yield new Promise(resolve => {
-        browser.messageManager.addMessageListener("Extension:ExtensionViewLoaded", function onLoad() {
-          browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad);
-          resolve();
-        });
-      });
-
-      if (browser.docShell) {
-        this.webNav = browser.docShell.QueryInterface(Ci.nsIWebNavigation);
+      if (this.browser.docShell) {
+        this.webNav = this.browser.docShell.QueryInterface(Ci.nsIWebNavigation);
         let window = this.webNav.document.defaultView;
 
-
         // Set the add-on's main debugger global, for use in the debugger
         // console.
         if (this.extension.addonData.instanceID) {
           AddonManager.getAddonByInstanceID(this.extension.addonData.instanceID)
                       .then(addon => addon.setDebugGlobal(window));
         }
       }
 
       this.extension.emit("startup");
     }.bind(this));
   }
 
-  initParentWindow(chromeShell) {
-    if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
-      let attrs = chromeShell.getOriginAttributes();
-      attrs.privateBrowsingId = 1;
-      chromeShell.setOriginAttributes(attrs);
-    }
-
-    let system = Services.scriptSecurityManager.getSystemPrincipal();
-    chromeShell.createAboutBlankContentViewer(system);
-    chromeShell.useGlobalHistory = false;
-    chromeShell.loadURI(XUL_URL, 0, null, null, null);
-
-    return promiseObserved("chrome-document-global-created",
-                           win => win.document == chromeShell.document);
-  }
-
   shutdown() {
     if (this.extension.addonData.instanceID) {
       AddonManager.getAddonByInstanceID(this.extension.addonData.instanceID)
                   .then(addon => addon.setDebugGlobal(null));
     }
 
-    if (this.browser) {
-      this.browser.remove();
-      this.browser = null;
-    }
-
-    // Navigate away from the background page to invalidate any
-    // setTimeouts or other callbacks.
-    if (this.webNav) {
-      this.webNav.loadURI("about:blank", 0, null, null, null);
-      this.webNav = null;