Bug 989960 - Unhandled rejections in DOM Promises should cause xpcshell tests to fail. r=Yoric
☠☠ backed out by 2c767d0968b9 ☠ ☠
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Fri, 29 Jan 2016 13:19:53 +0000
changeset 282390 3958782fe187026cbc3755ad6eae6920e67fa7d2
parent 282349 ccbe2dff012aa388153990e0a3e9c1323663270a
child 282391 e8d8faa2595513aca6258f0d9e224f86c6ecd01e
push id71158
push userphilringnalda@gmail.com
push dateSat, 30 Jan 2016 17:43:57 +0000
treeherdermozilla-inbound@5c14a66d6741 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersYoric
bugs989960
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 989960 - Unhandled rejections in DOM Promises should cause xpcshell tests to fail. r=Yoric
devtools/server/tests/unit/test_objectgrips-12.js
devtools/server/tests/unit/test_promises_actor_onpromisesettled.js
devtools/shared/acorn/tests/unit/head_acorn.js
devtools/shared/pretty-fast/tests/unit/head_pretty-fast.js
dom/promise/tests/unit/test_monitor_uncaught.js
dom/push/PushService.jsm
dom/push/PushServiceHttp2.jsm
dom/push/test/xpcshell/test_registration_success_http2.js
dom/push/test/xpcshell/test_unregister_success_http2.js
testing/xpcshell/head.js
testing/xpcshell/selftest.py
toolkit/components/jsdownloads/src/DownloadCore.jsm
toolkit/components/jsdownloads/src/DownloadImport.jsm
toolkit/components/jsdownloads/src/DownloadIntegration.jsm
toolkit/components/jsdownloads/src/DownloadLegacy.js
toolkit/components/jsdownloads/src/DownloadStore.jsm
toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js
toolkit/components/jsdownloads/test/unit/common_test_Download.js
toolkit/components/jsdownloads/test/unit/test_DownloadIntegration.js
toolkit/components/jsdownloads/test/unit/test_DownloadList.js
toolkit/components/search/nsSearchService.js
toolkit/components/search/tests/xpcshell/test_hidden.js
toolkit/modules/moz.build
toolkit/modules/tests/PromiseTestUtils.jsm
toolkit/modules/tests/xpcshell/test_ObjectUtils_strict.js
toolkit/modules/tests/xpcshell/test_Promise.js
toolkit/modules/tests/xpcshell/test_PromiseUtils.js
toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
--- a/devtools/server/tests/unit/test_objectgrips-12.js
+++ b/devtools/server/tests/unit/test_objectgrips-12.js
@@ -1,13 +1,15 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 // Test getDisplayString.
 
+Cu.import("resource://testing-common/PromiseTestUtils.jsm", this);
+
 var gDebuggee;
 var gClient;
 var gThreadClient;
 
 function run_test()
 {
   initTestDebuggerServer();
   gDebuggee = addTestGlobal("test-grips");
@@ -120,25 +122,28 @@ function test_display_string()
       input: "new Proxy({}, {})",
       output: "[object Object]"
     },
     {
       input: "Promise.resolve(5)",
       output: "Promise (fulfilled: 5)"
     },
     {
+      // This rejection is left uncaught, see expectUncaughtRejection below.
       input: "Promise.reject(new Error())",
       output: "Promise (rejected: Error)"
     },
     {
       input: "new Promise(function () {})",
       output: "Promise (pending)"
     }
   ];
 
+  PromiseTestUtils.expectUncaughtRejection(/Error/);
+
   gThreadClient.addOneTimeListener("paused", function(aEvent, aPacket) {
     const args = aPacket.frame.arguments;
 
     (function loop() {
       const objClient = gThreadClient.pauseGrip(args.pop());
       objClient.getDisplayString(function({ displayString }) {
         do_check_eq(displayString, testCases.pop().output);
         if (args.length) {
--- a/devtools/server/tests/unit/test_promises_actor_onpromisesettled.js
+++ b/devtools/server/tests/unit/test_promises_actor_onpromisesettled.js
@@ -3,16 +3,18 @@
 
 /**
  * Test that we can get the list of Promise objects that have settled from the
  * PromisesActor onPromiseSettled event handler.
  */
 
 "use strict";
 
+Cu.import("resource://testing-common/PromiseTestUtils.jsm", this);
+
 const { PromisesFront } = require("devtools/server/actors/promises");
 
 var events = require("sdk/event/core");
 
 add_task(function*() {
   let client = yield startTestDebuggerServer("promises-actor-test");
   let chromeActors = yield getChromeActors(client);
 
@@ -47,16 +49,17 @@ function* testPromisesSettled(client, fo
   yield front.attach();
   yield front.listPromises();
 
   let onPromiseSettled = oncePromiseSettled(front, resolution, true, false);
   let resolvedPromise = makeResolvePromise(resolution);
   let foundResolvedPromise = yield onPromiseSettled;
   ok(foundResolvedPromise, "Found our resolved promise");
 
+  PromiseTestUtils.expectUncaughtRejection(r => r.message == resolution);
   onPromiseSettled = oncePromiseSettled(front, resolution, false, true);
   let rejectedPromise = makeRejectPromise(resolution);
   let foundRejectedPromise = yield onPromiseSettled;
   ok(foundRejectedPromise, "Found our rejected promise");
 
   yield front.detach();
   // Appease eslint
   void resolvedPromise;
--- a/devtools/shared/acorn/tests/unit/head_acorn.js
+++ b/devtools/shared/acorn/tests/unit/head_acorn.js
@@ -57,14 +57,19 @@ var listener = {
       // no information.
       try {
         var string = "" + aMessage.message;
       } catch (x) {
         var string = "<error converting error message to string>";
       }
     }
 
+    // Ignored until they are fixed in bug 1242968.
+    if (string.includes("JavaScript Warning")) {
+      return;
+    }
+
     do_throw("head_acorn.js got console message: " + string + "\n");
   }
 };
 
 var consoleService = Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService);
 consoleService.registerListener(listener);
--- a/devtools/shared/pretty-fast/tests/unit/head_pretty-fast.js
+++ b/devtools/shared/pretty-fast/tests/unit/head_pretty-fast.js
@@ -30,15 +30,20 @@ var listener = {
       // no information.
       try {
         var string = "" + aMessage.message;
       } catch (x) {
         var string = "<error converting error message to string>";
       }
     }
 
+    // Ignored until they are fixed in bug 1242968.
+    if (string.includes("JavaScript Warning")) {
+      return;
+    }
+
     do_throw("head_pretty-fast.js got console message: " + string + "\n");
   }
 };
 
 var consoleService = Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService);
 consoleService.registerListener(listener);
 
--- a/dom/promise/tests/unit/test_monitor_uncaught.js
+++ b/dom/promise/tests/unit/test_monitor_uncaught.js
@@ -2,16 +2,20 @@
  * 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";
 
 var { utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Timer.jsm", this);
+Cu.import("resource://testing-common/PromiseTestUtils.jsm", this);
+
+// Prevent test failures due to the unhandled rejections in this test file.
+PromiseTestUtils.disableUncaughtRejectionObserverForSelfTest();
 
 add_task(function* test_globals() {
   Assert.equal(Promise.defer || undefined, undefined, "We are testing DOM Promise.");
   Assert.notEqual(PromiseDebugging, undefined, "PromiseDebugging is available.");
 });
 
 add_task(function* test_promiseID() {
   let p1 = new Promise(resolve => {});
--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -453,17 +453,17 @@ this.PushService = {
         return;
       }
 
       // Start service.
       this._startService(service, uri, options).then(_ => {
         // Before completing the activation check prefs. This will first check
         // connection.enabled pref and then check offline state.
         this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"));
-      });
+      }).catch(Cu.reportError);
 
     } else {
       // This is only used for testing. Different tests require connecting to
       // slightly different URLs.
       prefs.observe("serverURL", this);
 
       this._stateChangeProcessEnqueue(_ =>
         this._changeServerURL(prefs.get("serverURL"), STARTING_SERVICE_EVENT));
--- a/dom/push/PushServiceHttp2.jsm
+++ b/dom/push/PushServiceHttp2.jsm
@@ -721,22 +721,25 @@ this.PushServiceHttp2 = {
    *               - on error delete record and send pushsubscriptionchange
    *  TODO: maybe pushsubscriptionerror will be included.
    */
   _resubscribe: function(aSubscriptionUri) {
     this._mainPushService.getByKeyID(aSubscriptionUri)
       .then(record => this._subscribeResource(record)
         .then(recordNew => {
           if (this._mainPushService) {
-            this._mainPushService.updateRegistrationAndNotifyApp(aSubscriptionUri,
-                                                                 recordNew);
+            this._mainPushService
+                .updateRegistrationAndNotifyApp(aSubscriptionUri, recordNew)
+                .catch(Cu.reportError);
           }
         }, error => {
           if (this._mainPushService) {
-            this._mainPushService.dropRegistrationAndNotifyApp(aSubscriptionUri);
+            this._mainPushService
+                .dropRegistrationAndNotifyApp(aSubscriptionUri)
+                .catch(Cu.reportError);
           }
         })
       );
   },
 
   connOnStop: function(aRequest, aSuccess,
                        aSubscriptionUri) {
     console.debug("connOnStop() succeeded", aSuccess);
--- a/dom/push/test/xpcshell/test_registration_success_http2.js
+++ b/dom/push/test/xpcshell/test_registration_success_http2.js
@@ -1,14 +1,23 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 'use strict';
 
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/PromiseTestUtils.jsm");
+
+///////////////////
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+// Instances of the rejection "record is undefined" may or may not appear.
+PromiseTestUtils.thisTestLeaksUncaughtRejectionsAndShouldBeFixed();
 
 const {PushDB, PushService, PushServiceHttp2} = serviceExports;
 
 var prefs;
 
 var serverPort = -1;
 
 function run_test() {
--- a/dom/push/test/xpcshell/test_unregister_success_http2.js
+++ b/dom/push/test/xpcshell/test_unregister_success_http2.js
@@ -1,14 +1,23 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 'use strict';
 
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/PromiseTestUtils.jsm");
+
+///////////////////
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+// Instances of the rejection "record is undefined" may or may not appear.
+PromiseTestUtils.thisTestLeaksUncaughtRejectionsAndShouldBeFixed();
 
 const {PushDB, PushService, PushServiceHttp2} = serviceExports;
 
 var prefs;
 var tlsProfile;
 
 var serverPort = -1;
 
--- a/testing/xpcshell/head.js
+++ b/testing/xpcshell/head.js
@@ -17,16 +17,17 @@ var _cleanupFunctions = [];
 var _pendingTimers = [];
 var _profileInitialized = false;
 
 // Register the testing-common resource protocol early, to have access to its
 // modules.
 _register_modules_protocol_handler();
 
 var _Promise = Components.utils.import("resource://gre/modules/Promise.jsm", {}).Promise;
+var _PromiseTestUtils = Components.utils.import("resource://testing-common/PromiseTestUtils.jsm", {}).PromiseTestUtils;
 
 // Support a common assertion library, Assert.jsm.
 var AssertCls = Components.utils.import("resource://testing-common/Assert.jsm", null).Assert;
 // Pass a custom report function for xpcshell-test style reporting.
 var Assert = new AssertCls(function(err, message, stack) {
   if (err) {
     do_report_result(false, err.message, err.stack);
   } else {
@@ -208,17 +209,16 @@ function _do_main() {
     thr.processNextEvent(true);
 
   while (thr.hasPendingEvents())
     thr.processNextEvent(true);
 }
 
 function _do_quit() {
   _testLogger.info("exiting test");
-  _Promise.Debugging.flushUncaughtErrors();
   _quit = true;
 }
 
 /**
  * Overrides idleService with a mock.  Idle is commonly used for maintenance
  * tasks, thus if a test uses a service that requires the idle service, it will
  * start handling them.
  * This behaviour would cause random failures and slowdown tests execution,
@@ -494,26 +494,18 @@ function _execute_test() {
   }
 
   _register_protocol_handlers();
 
   // Override idle service by default.
   // Call do_get_idle() to restore the factory and get the service.
   _fakeIdleService.activate();
 
-  _Promise.Debugging.clearUncaughtErrorObservers();
-  _Promise.Debugging.addUncaughtErrorObserver(function observer({message, date, fileName, stack, lineNumber}) {
-    let text = " A promise chain failed to handle a rejection: " +
-        message + " - rejection date: " + date;
-    _testLogger.error(text,
-                      {
-                        stack: _format_stack(stack),
-                        source_file: fileName
-                      });
-  });
+  _PromiseTestUtils.init();
+  _PromiseTestUtils.Assert = Assert;
 
   // _HEAD_FILES is dynamically defined by <runxpcshelltests.py>.
   _load_files(_HEAD_FILES);
   // _TEST_FILE is dynamically defined by <runxpcshelltests.py>.
   _load_files(_TEST_FILE);
 
   // Tack Assert.jsm methods to the current scope.
   this.Assert = Assert;
@@ -534,16 +526,17 @@ function _execute_test() {
     // added by add_test() and add_task().
     if (typeof run_test === "function") {
       run_test();
     } else {
       run_next_test();
     }
     do_test_finished("MAIN run_test");
     _do_main();
+    _PromiseTestUtils.assertNoUncaughtRejections();
   } catch (e) {
     _passed = false;
     // do_check failures are already logged and set _quit to true and throw
     // NS_ERROR_ABORT. If both of those are true it is likely this exception
     // has already been logged so there is no need to log it again. It's
     // possible that this will mask an NS_ERROR_ABORT that happens after a
     // do_check failure though.
     if (!_quit || e != Components.results.NS_ERROR_ABORT) {
@@ -582,44 +575,51 @@ function _execute_test() {
     }
     _testLogger.error(_exception_message(ex),
                       {
                         stack: _format_stack(stack),
                         source_file: filename
                       });
   };
 
+  let thr = Components.classes["@mozilla.org/thread-manager;1"]
+              .getService().currentThread;
+
   let func;
   while ((func = _cleanupFunctions.pop())) {
     let result;
     try {
       result = func();
     } catch (ex) {
       reportCleanupError(ex);
       continue;
     }
     if (result && typeof result == "object"
         && "then" in result && typeof result.then == "function") {
       // This is a promise, wait until it is satisfied before proceeding
       let complete = false;
       let promise = result.then(null, reportCleanupError);
       promise = promise.then(() => complete = true);
-      let thr = Components.classes["@mozilla.org/thread-manager;1"]
-                  .getService().currentThread;
       while (!complete) {
         thr.processNextEvent(true);
       }
     }
   }
 
   // Restore idle service to avoid leaks.
   _fakeIdleService.deactivate();
 
-  if (!_passed)
-    return;
+  try {
+    _PromiseTestUtils.ensureDOMPromiseRejectionsProcessed();
+    _PromiseTestUtils.assertNoUncaughtRejections();
+    _PromiseTestUtils.assertNoMoreExpectedRejections();
+  } finally {
+    // It's important to terminate the module to avoid crashes on shutdown.
+    _PromiseTestUtils.uninit();
+  }
 }
 
 /**
  * Loads files.
  *
  * @param aFiles Array of files to load.
  */
 function _load_files(aFiles) {
@@ -1511,18 +1511,18 @@ function run_next_test()
     throw new Error("run_next_test() called from an add_task() test function. " +
                     "run_next_test() should not be called from inside add_task() " +
                     "under any circumstances!");
   }
 
   function _run_next_test()
   {
     if (_gTestIndex < _gTests.length) {
-      // Flush uncaught errors as early and often as possible.
-      _Promise.Debugging.flushUncaughtErrors();
+      // Check for uncaught rejections as early and often as possible.
+      _PromiseTestUtils.assertNoUncaughtRejections();
       let _properties;
       [_properties, _gRunningTest,] = _gTests[_gTestIndex++];
       if (typeof(_properties.skip_if) == "function" && _properties.skip_if()) {
         let _condition = _properties.skip_if.toSource().replace(/\(\)\s*=>\s*/, "");
         let _message = _gRunningTest.name
           + " skipped because the following conditions were"
           + " met: (" + _condition + ")";
         _testLogger.testStatus(_TEST_NAME,
@@ -1533,20 +1533,28 @@ function run_next_test()
         do_execute_soon(run_next_test);
         return;
       }
       _testLogger.info(_TEST_NAME + " | Starting " + _gRunningTest.name);
       do_test_pending(_gRunningTest.name);
 
       if (_properties._isTask) {
         _gTaskRunning = true;
-        _Task.spawn(_gRunningTest).then(
-          () => { _gTaskRunning = false; run_next_test(); },
-          (ex) => { _gTaskRunning = false; do_report_unexpected_exception(ex); }
-        );
+        _Task.spawn(_gRunningTest).then(() => {
+          _gTaskRunning = false;
+          run_next_test();
+        }, ex => {
+          _gTaskRunning = false;
+          try {
+            do_report_unexpected_exception(ex);
+          } catch (ex) {
+            // The above throws NS_ERROR_ABORT and we don't want this to show up
+            // as an unhandled rejection later.
+          }
+        });
       } else {
         // Exceptions do not kill asynchronous tests, so they'll time out.
         try {
           _gRunningTest();
         } catch (e) {
           do_throw(e);
         }
       }
--- a/testing/xpcshell/selftest.py
+++ b/testing/xpcshell/selftest.py
@@ -30,16 +30,33 @@ else:
     xpcshellBin += ".exe"
 
 TEST_PASS_STRING = "TEST-PASS"
 TEST_FAIL_STRING = "TEST-UNEXPECTED-FAIL"
 
 SIMPLE_PASSING_TEST = "function run_test() { do_check_true(true); }"
 SIMPLE_FAILING_TEST = "function run_test() { do_check_true(false); }"
 
+SIMPLE_UNCAUGHT_REJECTION_TEST = '''
+function run_test() {
+  Promise.reject(new Error("Test rejection."));
+  do_check_true(true);
+}
+'''
+
+SIMPLE_UNCAUGHT_REJECTION_JSM_TEST = '''
+Components.utils.import("resource://gre/modules/Promise.jsm");
+
+Promise.reject(new Error("Test rejection."));
+
+function run_test() {
+  do_check_true(true);
+}
+'''
+
 ADD_TEST_SIMPLE = '''
 function run_test() { run_next_test(); }
 
 add_test(function test_simple() {
   do_check_true(true);
   run_next_test();
 });
 '''
@@ -48,16 +65,36 @@ ADD_TEST_FAILING = '''
 function run_test() { run_next_test(); }
 
 add_test(function test_failing() {
   do_check_true(false);
   run_next_test();
 });
 '''
 
+ADD_TEST_UNCAUGHT_REJECTION = '''
+function run_test() { run_next_test(); }
+
+add_test(function test_uncaught_rejection() {
+  Promise.reject(new Error("Test rejection."));
+  run_next_test();
+});
+'''
+
+ADD_TEST_UNCAUGHT_REJECTION_JSM = '''
+Components.utils.import("resource://gre/modules/Promise.jsm");
+
+function run_test() { run_next_test(); }
+
+add_test(function test_uncaught_rejection() {
+  Promise.reject(new Error("Test rejection."));
+  run_next_test();
+});
+'''
+
 CHILD_TEST_PASSING = '''
 function run_test () { run_next_test(); }
 
 add_test(function test_child_simple () {
   run_test_in_child("test_pass.js");
   run_next_test();
 });
 '''
@@ -419,16 +456,17 @@ tail =
         """
         self.assertEquals(expected,
                           self.x.runTests(xpcshellBin,
                                           manifest=self.manifest,
                                           mozInfo=mozinfo.info,
                                           shuffle=shuffle,
                                           verbose=verbose,
                                           sequential=True,
+                                          testingModulesDir=os.path.join(objdir, '_tests', 'modules'),
                                           utility_path=self.utility_path),
                           msg="""Tests should have %s, log:
 ========
 %s
 ========
 """ % ("passed" if expected else "failed", self.log.getvalue()))
 
     def _assertLog(self, s, expected):
@@ -797,16 +835,40 @@ add_test({
         self.assertTestResult(False)
         self.assertEquals(1, self.x.testCount)
         self.assertEquals(0, self.x.passCount)
         self.assertEquals(1, self.x.failCount)
         self.assertEquals(0, self.x.todoCount)
         self.assertInLog(TEST_FAIL_STRING)
         self.assertNotInLog(TEST_PASS_STRING)
 
+    def testUncaughtRejection(self):
+        """
+        Ensure a simple test with an uncaught rejection is reported.
+        """
+        self.writeFile("test_simple_uncaught_rejection.js", SIMPLE_UNCAUGHT_REJECTION_TEST)
+        self.writeManifest(["test_simple_uncaught_rejection.js"])
+
+        self.assertTestResult(False)
+        self.assertEquals(1, self.x.testCount)
+        self.assertEquals(0, self.x.passCount)
+        self.assertEquals(1, self.x.failCount)
+
+    def testUncaughtRejectionJSM(self):
+        """
+        Ensure a simple test with an uncaught rejection from Promise.jsm is reported.
+        """
+        self.writeFile("test_simple_uncaught_rejection_jsm.js", SIMPLE_UNCAUGHT_REJECTION_JSM_TEST)
+        self.writeManifest(["test_simple_uncaught_rejection_jsm.js"])
+
+        self.assertTestResult(False)
+        self.assertEquals(1, self.x.testCount)
+        self.assertEquals(0, self.x.passCount)
+        self.assertEquals(1, self.x.failCount)
+
     def testAddTestSimple(self):
         """
         Ensure simple add_test() works.
         """
         self.writeFile("test_add_test_simple.js", ADD_TEST_SIMPLE)
         self.writeManifest(["test_add_test_simple.js"])
 
         self.assertTestResult(True)
@@ -834,16 +896,40 @@ add_test({
         self.writeFile("test_add_test_failing.js", ADD_TEST_FAILING)
         self.writeManifest(["test_add_test_failing.js"])
 
         self.assertTestResult(False)
         self.assertEquals(1, self.x.testCount)
         self.assertEquals(0, self.x.passCount)
         self.assertEquals(1, self.x.failCount)
 
+    def testAddTestUncaughtRejection(self):
+        """
+        Ensure add_test() with an uncaught rejection is reported.
+        """
+        self.writeFile("test_add_test_uncaught_rejection.js", ADD_TEST_UNCAUGHT_REJECTION)
+        self.writeManifest(["test_add_test_uncaught_rejection.js"])
+
+        self.assertTestResult(False)
+        self.assertEquals(1, self.x.testCount)
+        self.assertEquals(0, self.x.passCount)
+        self.assertEquals(1, self.x.failCount)
+
+    def testAddTestUncaughtRejectionJSM(self):
+        """
+        Ensure add_test() with an uncaught rejection from Promise.jsm is reported.
+        """
+        self.writeFile("test_add_test_uncaught_rejection_jsm.js", ADD_TEST_UNCAUGHT_REJECTION_JSM)
+        self.writeManifest(["test_add_test_uncaught_rejection_jsm.js"])
+
+        self.assertTestResult(False)
+        self.assertEquals(1, self.x.testCount)
+        self.assertEquals(0, self.x.passCount)
+        self.assertEquals(1, self.x.failCount)
+
     def testAddTaskTestSingle(self):
         """
         Ensure add_test_task() with a single passing test works.
         """
         self.writeFile("test_add_task_simple.js", ADD_TASK_SINGLE)
         self.writeManifest(["test_add_task_simple.js"])
 
         self.assertTestResult(True)
--- a/toolkit/components/jsdownloads/src/DownloadCore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm
@@ -2096,16 +2096,19 @@ this.DownloadCopySaver.prototype = {
           }
 
           // If the operation succeeded, store the object to allow cancellation.
           this._backgroundFileSaver = backgroundFileSaver;
         } catch (ex) {
           // In case an error occurs while setting up the chain of objects for
           // the download, ensure that we release the resources of the saver.
           backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE);
+          // Since we're not going to handle deferSaveComplete.promise below,
+          // we need to make sure that the rejection is handled.
+          deferSaveComplete.promise.catch(() => {});
           throw ex;
         }
 
         // We will wait on this promise in case no error occurred while setting
         // up the chain of objects for the download.
         yield deferSaveComplete.promise;
 
         yield this._checkReputationAndMove();
--- a/toolkit/components/jsdownloads/src/DownloadImport.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadImport.jsm
@@ -169,17 +169,17 @@ this.DownloadImport.prototype = {
               downloadOptions.canceled = true;
             }
 
             let download = yield Downloads.createDownload(downloadOptions);
 
             yield this.list.add(download);
 
             if (resumeDownload) {
-              download.start();
+              download.start().catch(() => {});
             } else {
               yield download.refresh();
             }
 
           } catch (ex) {
             Cu.reportError("Error importing download: " + ex);
           }
         }
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -1075,17 +1075,17 @@ this.DownloadObserver = {
   /**
    * Resume all downloads that were paused when going offline, used when waking
    * from sleep or returning from being offline.
    */
   _resumeOfflineDownloads: function DO_resumeOfflineDownloads() {
     this._wakeTimer = null;
 
     for (let download of this._canceledOfflineDownloads) {
-      download.start();
+      download.start().catch(() => {});
     }
   },
 
   ////////////////////////////////////////////////////////////////////////////
   //// nsIObserver
 
   observe: function DO_observe(aSubject, aTopic, aData) {
     let downloadsCount;
--- a/toolkit/components/jsdownloads/src/DownloadLegacy.js
+++ b/toolkit/components/jsdownloads/src/DownloadLegacy.js
@@ -238,17 +238,17 @@ DownloadLegacyTransfer.prototype = {
       launcherPath: launcherPath
     }).then(function DLT_I_onDownload(aDownload) {
       // Legacy components keep partial data when they use a ".part" file.
       if (aTempFile) {
         aDownload.tryToKeepPartialData = true;
       }
 
       // Start the download before allowing it to be controlled.  Ignore errors.
-      aDownload.start().then(null, () => {});
+      aDownload.start().catch(() => {});
 
       // Start processing all the other events received through nsITransfer.
       this._deferDownload.resolve(aDownload);
 
       // Add the download to the list, allowing it to be seen and canceled.
       return Downloads.getList(Downloads.ALL).then(list => list.add(aDownload));
     }.bind(this)).then(null, Cu.reportError);
   },
--- a/toolkit/components/jsdownloads/src/DownloadStore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadStore.jsm
@@ -119,18 +119,18 @@ this.DownloadStore.prototype = {
 
       // Create live downloads based on the static snapshot.
       for (let downloadData of storeData.list) {
         try {
           let download = yield Downloads.createDownload(downloadData);
           try {
             if (!download.succeeded && !download.canceled && !download.error) {
               // Try to restart the download if it was in progress during the
-              // previous session.
-              download.start();
+              // previous session.  Ignore errors.
+              download.start().catch(() => {});
             } else {
               // If the download was not in progress, try to update the current
               // progress from disk.  This is relevant in case we retained
               // partially downloaded data.
               yield download.refresh();
             }
           } finally {
             // Add the download to the list if we succeeded in creating it,
--- a/toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js
+++ b/toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js
@@ -83,17 +83,17 @@ add_task(function* test_cancel_pdf_downl
 
   let download = yield Downloads.createDownload({
     source: tab.linkedBrowser.contentWindow,
     target: { path: getTempFile(TEST_TARGET_FILE_NAME_PDF).path },
     saver: "pdf",
   });
 
   yield test_download_windowRef(tab, download);
-  download.start();
+  download.start().catch(() => {});
 
   // Immediately cancel the download to test that it is erased correctly.
   yield download.cancel();
   yield test_download_state_complete(tab, download, false, true);
 
   let exists = yield OS.File.exists(download.target.path)
   ok(!exists, "Target file does not exist");
 
--- a/toolkit/components/jsdownloads/test/unit/common_test_Download.js
+++ b/toolkit/components/jsdownloads/test/unit/common_test_Download.js
@@ -27,17 +27,17 @@ const kDeleteTempFileOnExit = "browser.h
  * @rejects JavaScript exception.
  */
 function promiseStartDownload(aSourceUrl) {
   if (gUseLegacySaver) {
     return promiseStartLegacyDownload(aSourceUrl);
   }
 
   return promiseNewDownload(aSourceUrl).then(download => {
-    download.start();
+    download.start().catch(() => {});
     return download;
   });
 }
 
 /**
  * Creates and starts a new download, configured to keep partial data, and
  * returns only when the first part of "interruptible_resumable.txt" has been
  * saved to disk.  You must call "continueResponses" to allow the interruptible
@@ -59,17 +59,17 @@ function promiseStartDownload_tryToKeepP
     if (!gUseLegacySaver) {
       let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
       download = yield Downloads.createDownload({
         source: httpUrl("interruptible_resumable.txt"),
         target: { path: targetFilePath,
                   partFilePath: targetFilePath + ".part" },
       });
       download.tryToKeepPartialData = true;
-      download.start();
+      download.start().catch(() => {});
     } else {
       // Start a download using nsIExternalHelperAppService, that is configured
       // to keep partially downloaded data by default.
       download = yield promiseStartExternalHelperAppServiceDownload();
     }
 
     yield promiseDownloadMidway(download);
     yield promisePartFileReady(download);
@@ -430,17 +430,17 @@ add_task(function* test_empty_progress_t
   if (!gUseLegacySaver) {
     let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
     download = yield Downloads.createDownload({
       source: httpUrl("empty.txt"),
       target: { path: targetFilePath,
                 partFilePath: targetFilePath + ".part" },
     });
     download.tryToKeepPartialData = true;
-    download.start();
+    download.start().catch(() => {});
   } else {
     // Start a download using nsIExternalHelperAppService, that is configured
     // to keep partially downloaded data by default.
     download = yield promiseStartExternalHelperAppServiceDownload(
                                                          httpUrl("empty.txt"));
   }
   yield promiseDownloadStopped(download);
 
@@ -486,17 +486,17 @@ add_task(function* test_empty_noprogress
     download.onchange = function () {
       if (!download.stopped) {
         do_check_false(download.hasProgress);
         do_check_eq(download.currentBytes, 0);
         do_check_eq(download.totalBytes, 0);
       }
     };
 
-    download.start();
+    download.start().catch(() => {});
   } else {
     // When testing DownloadLegacySaver, the download is already started when it
     // is created, and it may have already made all needed property change
     // notifications, thus there is no point in checking the onchange callback.
     download = yield promiseStartLegacyDownload(sourceUrl);
   }
 
   // Wait for the request to be received by the HTTP server, but don't allow the
@@ -851,17 +851,17 @@ add_task(function* test_cancel_midway_re
   do_check_true(download.hasPartialData);
   yield promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT);
 
   yield download.removePartialData();
   do_check_false(yield OS.File.exists(download.target.partFilePath));
 
   // Restart the download from the beginning.
   mustInterruptResponses();
-  download.start();
+  download.start().catch(() => {});
 
   yield promiseDownloadMidway(download);
   yield promisePartFileReady(download);
 
   // While the download is in progress, we should still have a ".part" file.
   do_check_false(download.hasPartialData);
   do_check_true(yield OS.File.exists(download.target.partFilePath));
 
@@ -1138,30 +1138,30 @@ add_task(function* test_whenSucceeded_af
   let promiseSucceeded;
 
   let download;
   if (!gUseLegacySaver) {
     // When testing DownloadCopySaver, we have control over the download, thus
     // we can verify getting a reference before the first download attempt.
     download = yield promiseNewDownload(httpUrl("interruptible.txt"));
     promiseSucceeded = download.whenSucceeded();
-    download.start();
+    download.start().catch(() => {});
   } else {
     // When testing DownloadLegacySaver, the download is already started when it
     // is created, thus we cannot get the reference before the first attempt.
     download = yield promiseStartLegacyDownload(httpUrl("interruptible.txt"));
     promiseSucceeded = download.whenSucceeded();
   }
 
   // Cancel the first download attempt.
   yield download.cancel();
 
   // The second request is allowed to complete.
   continueResponses();
-  download.start();
+  download.start().catch(() => {});
 
   // Wait for the download to finish by waiting on the whenSucceeded promise.
   yield promiseSucceeded;
 
   do_check_true(download.stopped);
   do_check_true(download.succeeded);
   do_check_false(download.canceled);
   do_check_true(download.error === null);
@@ -1338,17 +1338,17 @@ add_task(function* test_error_restart()
   try {
     // Use DownloadCopySaver or DownloadLegacySaver based on the test run,
     // specifying the target file we created.
     if (!gUseLegacySaver) {
       download = yield Downloads.createDownload({
         source: httpUrl("source.txt"),
         target: targetFile,
       });
-      download.start();
+      download.start().catch(() => {});
     } else {
       download = yield promiseStartLegacyDownload(null,
                                                   { targetFile: targetFile });
     }
     yield promiseDownloadStopped(download);
     do_throw("The download should have failed.");
   } catch (ex) {
     if (!(ex instanceof Downloads.Error) || !ex.becauseTargetFailed) {
@@ -2181,17 +2181,17 @@ add_task(function* test_platform_integra
       download = yield promiseStartLegacyDownload(httpUrl("source.txt"),
                                                   { targetFile: targetFile });
     }
     else {
       download = yield Downloads.createDownload({
         source: httpUrl("source.txt"),
         target: targetFile,
       });
-      download.start();
+      download.start().catch(() => {});
     }
 
     // Wait for the whenSucceeded promise to be resolved first.
     // downloadDone should be called before the whenSucceeded promise is resolved.
     yield download.whenSucceeded().then(function () {
       do_check_true(DownloadIntegration.downloadDoneCalled);
       do_check_true(downloadWatcherNotified);
     });
--- a/toolkit/components/jsdownloads/test/unit/test_DownloadIntegration.js
+++ b/toolkit/components/jsdownloads/test/unit/test_DownloadIntegration.js
@@ -210,17 +210,17 @@ add_task(function* test_notifications()
     mustInterruptResponses();
 
     let list = yield promiseNewList(isPrivate);
     let download1 = yield promiseNewDownload(httpUrl("interruptible.txt"));
     let download2 = yield promiseNewDownload(httpUrl("interruptible.txt"));
     let download3 = yield promiseNewDownload(httpUrl("interruptible.txt"));
     let promiseAttempt1 = download1.start();
     let promiseAttempt2 = download2.start();
-    download3.start();
+    download3.start().catch(() => {});
 
     // Add downloads to list.
     yield list.add(download1);
     yield list.add(download2);
     yield list.add(download3);
     // Cancel third download
     yield download3.cancel();
 
@@ -245,18 +245,18 @@ add_task(function* test_notifications()
 add_task(function* test_no_notifications()
 {
   enableObserversTestMode();
 
   for (let isPrivate of [false, true]) {
     let list = yield promiseNewList(isPrivate);
     let download1 = yield promiseNewDownload(httpUrl("interruptible.txt"));
     let download2 = yield promiseNewDownload(httpUrl("interruptible.txt"));
-    download1.start();
-    download2.start();
+    download1.start().catch(() => {});
+    download2.start().catch(() => {});
 
     // Add downloads to list.
     yield list.add(download1);
     yield list.add(download2);
 
     yield download1.cancel();
     yield download2.cancel();
 
@@ -311,17 +311,17 @@ add_task(function* test_suspend_resume()
   // The default wake delay is 10 seconds, so set the wake delay to be much
   // faster for these tests.
   Services.prefs.setIntPref("browser.download.manager.resumeOnWakeDelay", 5);
 
   let addDownload = function(list)
   {
     return Task.spawn(function* () {
       let download = yield promiseNewDownload(httpUrl("interruptible.txt"));
-      download.start();
+      download.start().catch(() => {});
       list.add(download);
       return download;
     });
   }
 
   let publicList = yield promiseNewList();
   let privateList = yield promiseNewList(true);
 
--- a/toolkit/components/jsdownloads/test/unit/test_DownloadList.js
+++ b/toolkit/components/jsdownloads/test/unit/test_DownloadList.js
@@ -343,17 +343,17 @@ add_task(function* test_history_expirati
         deferred.resolve();
       }
     },
   };
   yield list.addView(downloadView);
 
   // Work with one finished download and one canceled download.
   yield downloadOne.start();
-  downloadTwo.start();
+  downloadTwo.start().catch(() => {});
   yield downloadTwo.cancel();
 
   // We must replace the visits added while executing the downloads with visits
   // that are older than 7 days, otherwise they will not be expired.
   yield PlacesTestUtils.clearHistory();
   yield promiseExpirableDownloadVisit();
   yield promiseExpirableDownloadVisit(httpUrl("interruptible.txt"));
 
@@ -466,34 +466,34 @@ add_task(function* test_DownloadSummary(
   // Add a public download that has succeeded.
   let succeededPublicDownload = yield promiseNewDownload();
   yield succeededPublicDownload.start();
   yield publicList.add(succeededPublicDownload);
 
   // Add a public download that has been canceled midway.
   let canceledPublicDownload =
       yield promiseNewDownload(httpUrl("interruptible.txt"));
-  canceledPublicDownload.start();
+  canceledPublicDownload.start().catch(() => {});
   yield promiseDownloadMidway(canceledPublicDownload);
   yield canceledPublicDownload.cancel();
   yield publicList.add(canceledPublicDownload);
 
   // Add a public download that is in progress.
   let inProgressPublicDownload =
       yield promiseNewDownload(httpUrl("interruptible.txt"));
-  inProgressPublicDownload.start();
+  inProgressPublicDownload.start().catch(() => {});
   yield promiseDownloadMidway(inProgressPublicDownload);
   yield publicList.add(inProgressPublicDownload);
 
   // Add a private download that is in progress.
   let inProgressPrivateDownload = yield Downloads.createDownload({
     source: { url: httpUrl("interruptible.txt"), isPrivate: true },
     target: getTempFile(TEST_TARGET_FILE_NAME).path,
   });
-  inProgressPrivateDownload.start();
+  inProgressPrivateDownload.start().catch(() => {});
   yield promiseDownloadMidway(inProgressPrivateDownload);
   yield privateList.add(inProgressPrivateDownload);
 
   // Verify that the summary includes the total number of bytes and the
   // currently transferred bytes only for the downloads that are not stopped.
   // For simplicity, we assume that after a download is added to the list, its
   // current state is immediately propagated to the summary object, which is
   // true in the current implementation, though it is not guaranteed as all the
--- a/toolkit/components/search/nsSearchService.js
+++ b/toolkit/components/search/nsSearchService.js
@@ -4448,16 +4448,23 @@ SearchService.prototype = {
 
       // Schedule the next update
       engineUpdateService.scheduleNextUpdate(engine);
 
     } // end engine iteration
   },
 
   _addObservers: function SRCH_SVC_addObservers() {
+    if (this._observersAdded) {
+      // There might be a race between synchronous and asynchronous
+      // initialization for which we try to register the observers twice.
+      return;
+    }
+    this._observersAdded = true;
+
     Services.obs.addObserver(this, SEARCH_ENGINE_TOPIC, false);
     Services.obs.addObserver(this, QUIT_APPLICATION_TOPIC, false);
 
 #ifdef MOZ_FENNEC
     Services.prefs.addObserver(LOCALE_PREF, this, false);
 #endif
 
     // The current stage of shutdown. Used to help analyze crash
@@ -4490,16 +4497,17 @@ SearchService.prototype = {
             Promise.reject(ex);
           }
         }
       }.bind(this)),
 
       () => shutdownState
     );
   },
+  _observersAdded: false,
 
   _removeObservers: function SRCH_SVC_removeObservers() {
     Services.obs.removeObserver(this, SEARCH_ENGINE_TOPIC);
     Services.obs.removeObserver(this, QUIT_APPLICATION_TOPIC);
 
 #ifdef MOZ_FENNEC
     Services.prefs.removeObserver(LOCALE_PREF, this);
 #endif
--- a/toolkit/components/search/tests/xpcshell/test_hidden.js
+++ b/toolkit/components/search/tests/xpcshell/test_hidden.js
@@ -42,20 +42,16 @@ add_task(function* async_init() {
   // The next test does a sync init, which won't do the geoSpecificDefaults XHR,
   // so it depends on the metadata having been written to disk.
   yield commitPromise;
 });
 
 add_task(function* sync_init() {
   let reInitPromise = asyncReInit();
   // Synchronously check the current default engine, to force a sync init.
-  // XXX For some reason forcing a sync init while already asynchronously
-  // reinitializing causes a shutdown warning related to engineMetadataService's
-  // finalize method having already been called. Seems harmless for the purpose
-  // of this test.
   do_check_false(Services.search.isInitialized);
   do_check_eq(Services.search.currentEngine.name, "hidden");
   do_check_true(Services.search.isInitialized);
 
   let engines = Services.search.getEngines();
   do_check_eq(engines.length, 1);
 
   // The default test jar engine has been hidden.
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -4,16 +4,20 @@
 # 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/.
 
 XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini']
 BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini']
 MOCHITEST_MANIFESTS += ['tests/mochitest/mochitest.ini']
 MOCHITEST_CHROME_MANIFESTS += ['tests/chrome/chrome.ini']
 
+TESTING_JS_MODULES += [
+    'tests/PromiseTestUtils.jsm',
+]
+
 SPHINX_TREES['toolkit_modules'] = 'docs'
 
 EXTRA_JS_MODULES += [
     'addons/MatchPattern.jsm',
     'addons/WebNavigation.jsm',
     'addons/WebNavigationContent.js',
     'addons/WebRequest.jsm',
     'addons/WebRequestCommon.jsm',
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/PromiseTestUtils.jsm
@@ -0,0 +1,241 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Detects and reports unhandled rejections during test runs. Test harnesses
+ * will fail tests in this case, unless the test whitelists itself.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "PromiseTestUtils",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm", this);
+
+// Keep "JSMPromise" separate so "Promise" still refers to DOM Promises.
+let JSMPromise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+
+// For now, we need test harnesses to provide a reference to Assert.jsm.
+let Assert = null;
+
+this.PromiseTestUtils = {
+  /**
+   * Array of objects containing the details of the Promise rejections that are
+   * currently left uncaught. This includes DOM Promise and Promise.jsm. When
+   * rejections in DOM Promises are consumed, they are removed from this list.
+   *
+   * The objects contain at least the following properties:
+   * {
+   *   message: The error message associated with the rejection, if any.
+   *   date: Date object indicating when the rejection was observed.
+   *   id: For DOM Promise only, the Promise ID from PromiseDebugging. This is
+   *       only used for tracking and should not be checked by the callers.
+   *   stack: nsIStackFrame, SavedFrame, or string indicating the stack at the
+   *          time the rejection was triggered. May also be null if the
+   *          rejection was triggered while a script was on the stack.
+   * }
+   */
+  _rejections: [],
+
+  /**
+   * When an uncaught rejection is detected, it is ignored if one of the
+   * functions in this array returns true when called with the rejection details
+   * as its only argument. When a function matches an expected rejection, it is
+   * then removed from the array.
+   */
+  _rejectionIgnoreFns: [],
+
+  /**
+   * Called only by the test infrastructure, registers the rejection observers.
+   *
+   * This should be called only once, and a matching "uninit" call must be made
+   * or the tests will crash on shutdown.
+   */
+  init() {
+    if (this._initialized) {
+      Cu.reportError("This object was already initialized.");
+      return;
+    }
+
+    PromiseDebugging.addUncaughtRejectionObserver(this);
+
+    // Promise.jsm rejections are only reported to this observer when requested,
+    // so we don't have to store a key to remove them when consumed.
+    JSMPromise.Debugging.addUncaughtErrorObserver(
+                            rejection => this._rejections.push(rejection));
+
+    this._initialized = true;
+  },
+  _initialized: false,
+
+  /**
+   * Called only by the test infrastructure, unregisters the observers.
+   */
+  uninit() {
+    if (!this._initialized) {
+      return;
+    }
+
+    PromiseDebugging.removeUncaughtRejectionObserver(this);
+    JSMPromise.Debugging.clearUncaughtErrorObservers();
+
+    this._initialized = false;
+  },
+
+  /**
+   * Called only by the test infrastructure, spins the event loop until the
+   * messages for pending DOM Promise rejections have been processed.
+   */
+  ensureDOMPromiseRejectionsProcessed() {
+    let observed = false;
+    let observer = {
+      onLeftUncaught: promise => {
+        if (PromiseDebugging.getState(promise).reason ===
+            this._ensureDOMPromiseRejectionsProcessedReason) {
+          observed = true;
+        }
+      },
+      onConsumed() {},
+    };
+
+    PromiseDebugging.addUncaughtRejectionObserver(observer);
+    Promise.reject(this._ensureDOMPromiseRejectionsProcessedReason);
+    while (!observed) {
+      Services.tm.mainThread.processNextEvent(true);
+    }
+    PromiseDebugging.removeUncaughtRejectionObserver(observer);
+  },
+  _ensureDOMPromiseRejectionsProcessedReason: {},
+
+  /**
+   * Called only by the tests for PromiseDebugging.addUncaughtRejectionObserver
+   * and for JSMPromise.Debugging, disables the observers in this module.
+   */
+  disableUncaughtRejectionObserverForSelfTest() {
+    this.uninit();
+  },
+
+  /**
+   * Called by tests that have been whitelisted, disables the observers in this
+   * module. For new tests where uncaught rejections are expected, you should
+   * use the more granular expectUncaughtRejection function instead.
+   */
+  thisTestLeaksUncaughtRejectionsAndShouldBeFixed() {
+    this.uninit();
+  },
+
+  /**
+   * Sets or updates the Assert object instance to be used for error reporting.
+   */
+  set Assert(assert) {
+    Assert = assert;
+  },
+
+  // UncaughtRejectionObserver
+  onLeftUncaught(promise) {
+    let message = "(Unable to convert rejection reason to string.)";
+    try {
+      let reason = PromiseDebugging.getState(promise).reason;
+      if (reason === this._ensureDOMPromiseRejectionsProcessedReason) {
+        // Ignore the special promise for ensureDOMPromiseRejectionsProcessed.
+        return;
+      }
+      message = reason.message || ("" + reason);
+    } catch (ex) {}
+
+    // It's important that we don't store any reference to the provided Promise
+    // object or its value after this function returns in order to avoid leaks.
+    this._rejections.push({
+      id: PromiseDebugging.getPromiseID(promise),
+      message,
+      date: new Date(),
+      stack: PromiseDebugging.getRejectionStack(promise),
+    });
+  },
+
+  // UncaughtRejectionObserver
+  onConsumed(promise) {
+    // We don't expect that many unhandled rejections will appear at the same
+    // time, so the algorithm doesn't need to be optimized for that case.
+    let id = PromiseDebugging.getPromiseID(promise);
+    let index = this._rejections.findIndex(rejection => rejection.id == id);
+    // If we get a consumption notification for a rejection that was left
+    // uncaught before this module was initialized, we can safely ignore it.
+    if (index != -1) {
+      this._rejections.splice(index, 1);
+    }
+  },
+
+  /**
+   * Informs the test suite that the test code will generate a Promise rejection
+   * that will still be unhandled when the test file terminates.
+   *
+   * This method must be called once for each instance of Promise that is
+   * expected to be uncaught, even if the rejection reason is the same for each
+   * instance.
+   *
+   * If the expected rejection does not occur, the test will fail.
+   *
+   * @param regExpOrCheckFn
+   *        This can either be a regular expression that should match the error
+   *        message of the rejection, or a check function that is invoked with
+   *        the rejection details object as its first argument.
+   */
+  expectUncaughtRejection(regExpOrCheckFn) {
+    let checkFn = !("test" in regExpOrCheckFn) ? regExpOrCheckFn :
+                  rejection => regExpOrCheckFn.test(rejection.message);
+    this._rejectionIgnoreFns.push(checkFn);
+  },
+
+  /**
+   * Fails the test if there are any uncaught rejections at this time that have
+   * not been whitelisted using expectUncaughtRejection.
+   *
+   * Depending on the configuration of the test suite, this function might only
+   * report the details of the first uncaught rejection that was generated.
+   *
+   * This is called by the test suite at the end of each test function.
+   */
+  assertNoUncaughtRejections() {
+    // Ask Promise.jsm to report all uncaught rejections to the observer now.
+    JSMPromise.Debugging.flushUncaughtErrors();
+
+    // If there is any uncaught rejection left at this point, the test fails.
+    while (this._rejections.length > 0) {
+      let rejection = this._rejections.shift();
+
+      // If one of the ignore functions matches, ignore the rejection, then
+      // remove the function so that each function only matches one rejection.
+      let index = this._rejectionIgnoreFns.findIndex(f => f(rejection));
+      if (index != -1) {
+        this._rejectionIgnoreFns.splice(index, 1);
+        continue;
+      }
+
+      // Report the error. This operation can throw an exception, depending on
+      // the configuration of the test suite that handles the assertion.
+      Assert.ok(false,
+                `A promise chain failed to handle a rejection:` +
+                ` ${rejection.message} - rejection date: ${rejection.date}`+
+                ` - stack: ${rejection.stack}`);
+    }
+  },
+
+  /**
+   * Fails the test if any rejection indicated by expectUncaughtRejection has
+   * not yet been reported at this time.
+   *
+   * This is called by the test suite at the end of each test file.
+   */
+  assertNoMoreExpectedRejections() {
+    // Only log this condition is there is a failure.
+    if (this._rejectionIgnoreFns.length > 0) {
+      Assert.equal(this._rejectionIgnoreFns.length, 0,
+             "Unable to find a rejection expected by expectUncaughtRejection.");
+    }
+  },
+};
--- a/toolkit/modules/tests/xpcshell/test_ObjectUtils_strict.js
+++ b/toolkit/modules/tests/xpcshell/test_ObjectUtils_strict.js
@@ -1,31 +1,28 @@
 "use strict";
 
 var {ObjectUtils} = Components.utils.import("resource://gre/modules/ObjectUtils.jsm", {});
-var {Promise} = Components.utils.import("resource://gre/modules/Promise.jsm", {});
-
-add_task(function* init() {
-  // The code will cause uncaught rejections on purpose.
-  Promise.Debugging.clearUncaughtErrorObservers();
-});
+var {PromiseTestUtils} = Components.utils.import("resource://testing-common/PromiseTestUtils.jsm", {});
 
 add_task(function* test_strict() {
   let loose = { a: 1 };
   let strict = ObjectUtils.strict(loose);
 
   loose.a; // Should not throw.
   loose.b || undefined; // Should not throw.
 
   strict.a; // Should not throw.
+  PromiseTestUtils.expectUncaughtRejection(/No such property: "b"/);
   Assert.throws(() => strict.b, /No such property: "b"/);
   "b" in strict; // Should not throw.
   strict.b = 2;
   strict.b; // Should not throw.
 
+  PromiseTestUtils.expectUncaughtRejection(/No such property: "c"/);
   Assert.throws(() => strict.c, /No such property: "c"/);
   "c" in strict; // Should not throw.
   loose.c = 3;
   strict.c; // Should not throw.
 });
 
 function run_test() {
   run_next_test();
--- a/toolkit/modules/tests/xpcshell/test_Promise.js
+++ b/toolkit/modules/tests/xpcshell/test_Promise.js
@@ -1,19 +1,19 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
 Components.utils.import("resource://gre/modules/Promise.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm");
 Components.utils.import("resource://gre/modules/Task.jsm");
+Components.utils.import("resource://testing-common/PromiseTestUtils.jsm");
 
-// Deactivate the standard xpcshell observer, as it turns uncaught
-// rejections into failures, which we don't want here.
-Promise.Debugging.clearUncaughtErrorObservers();
+// Prevent test failures due to the unhandled rejections in this test file.
+PromiseTestUtils.disableUncaughtRejectionObserverForSelfTest();
 
 ////////////////////////////////////////////////////////////////////////////////
 //// Test runner
 
 var run_promise_tests = function run_promise_tests(tests, cb) {
   let loop = function loop(index) {
     if (index >= tests.length) {
       if (cb) {
--- a/toolkit/modules/tests/xpcshell/test_PromiseUtils.js
+++ b/toolkit/modules/tests/xpcshell/test_PromiseUtils.js
@@ -1,15 +1,16 @@
   /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 Components.utils.import("resource://gre/modules/PromiseUtils.jsm");
 Components.utils.import("resource://gre/modules/Timer.jsm");
+Components.utils.import("resource://testing-common/PromiseTestUtils.jsm");
 
 // Tests for PromiseUtils.jsm
 function run_test() {
   run_next_test();
 }
 
 ///////////////////////////////////////////////////////////////////////////////////////
 // Tests for PromiseUtils.defer()
@@ -93,13 +94,14 @@ add_task(function* test_reject_resolved_
   let p = new Promise((resolve, reject) => resolve("This resolved"));
   def.reject(p);
   yield Assert.rejects(def.promise, Promise, "Rejection with a resolved promise uses the passed promise itself as the reason of rejection");
 });
 
 /* Test for the case when a rejected Promise is
  * passed to the reject method */
 add_task(function* test_reject_resolved_promise() {
+  PromiseTestUtils.expectUncaughtRejection(/This one rejects/);
   let def = PromiseUtils.defer();
-  let p = new Promise((resolve, reject) => reject(new Error("This on rejects")));
+  let p = new Promise((resolve, reject) => reject(new Error("This one rejects")));
   def.reject(p);
   yield Assert.rejects(def.promise, Promise, "Rejection with a rejected promise uses the passed promise itself as the reason of rejection");
 });
--- a/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
@@ -1,10 +1,22 @@
 "use strict";
 
+Components.utils.import("resource://testing-common/PromiseTestUtils.jsm", this);
+
+///////////////////
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+// thisTestLeaksUncaughtRejectionsAndShouldBeFixed
+if (!TEST_UNPACKED) {
+  PromiseTestUtils.expectUncaughtRejection(/NS_ERROR_FILE_NOT_FOUND/);
+}
+
 const TOOLKIT_ID = "toolkit@mozilla.org";
 
 // We don't have an easy way to serve update manifests from a secure URL.
 Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
 
 var testserver = createHttpServer();
 gPort = testserver.identity.primaryPort;
 
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
@@ -1,12 +1,27 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
+Components.utils.import("resource://testing-common/PromiseTestUtils.jsm", this);
+
+///////////////////
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+// thisTestLeaksUncaughtRejectionsAndShouldBeFixed
+if (TEST_UNPACKED) {
+  let codeAsString = ("" + Components.results.NS_ERROR_FILE_NOT_FOUND);
+  PromiseTestUtils.expectUncaughtRejection(r => r.message == codeAsString);
+} else {
+  PromiseTestUtils.expectUncaughtRejection(/Failed to open input source/);
+}
+
 const ID = "webextension1@tests.mozilla.org";
 
 const PREF_SELECTED_LOCALE = "general.useragent.locale";
 
 const profileDir = gProfD.clone();
 profileDir.append("extensions");
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
@@ -1,12 +1,29 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
+Components.utils.import("resource://testing-common/PromiseTestUtils.jsm", this);
+
+///////////////////
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+// thisTestLeaksUncaughtRejectionsAndShouldBeFixed
+for (let i = 0; i < 4; i++) {
+  if (TEST_UNPACKED) {
+    let codeAsString = ("" + Components.results.NS_ERROR_FILE_NOT_FOUND);
+    PromiseTestUtils.expectUncaughtRejection(r => r.message == codeAsString);
+  } else {
+    PromiseTestUtils.expectUncaughtRejection(/Failed to open input source/);
+  }
+}
+
 const ID = "webextension1@tests.mozilla.org";
 
 const profileDir = gProfD.clone();
 profileDir.append("extensions");
 profileDir.create(AM_Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
 startupManager();