diff --git a/addon-test-support/ember-qunit/index.js b/addon-test-support/ember-qunit/index.js index c28d8592..693d9add 100644 --- a/addon-test-support/ember-qunit/index.js +++ b/addon-test-support/ember-qunit/index.js @@ -222,6 +222,43 @@ export function setupEmberOnerrorValidation() { }); } +/** + Ensures unhandled native promise rejections are tracked and fail tests + properly. This brings native promise rejections in line with + Ember.RSVP.Promise rejections (which already have this behavior). + + Please note: This only works on Chrome (other browsers simply never hit this + code) since the `unhandledrejection` event is only fired on Chrome (>= 49). + */ +export function setupUnhandledRejectionHandler() { + // only needed until https://github.com/qunitjs/qunit/pull/1241 lands and is + // included in a release that ember-qunit can depend upon + + // Listen for unhandled rejections, and call QUnit.onError. + window.addEventListener('unhandledrejection', __unhandledRejectionHandler__); +} + +export function __unhandledRejectionHandler__(event) { + let { reason } = event; + let resultInfo = { + result: false, + message: reason.message || 'error', + actual: reason, + source: reason.stack, + }; + + let currentTest = QUnit.config.current; + if (currentTest) { + currentTest.assert.pushResult(resultInfo); + } else { + let test = assert => { + assert.pushResult(resultInfo); + }; + test.validTest = true; + QUnit.test('global failure', test); + } +} + /** @method start @param {Object} [options] Options to be used for enabling/disabling behaviors @@ -237,6 +274,8 @@ export function setupEmberOnerrorValidation() { back to `false` after each test will. @param {Boolean} [options.setupEmberOnerrorValidation] If `false` validation of `Ember.onerror` will be disabled. + @param {Boolean} [options.setupUnhandledRejectionHandler=true] Adds an `unhandledRejection` event listener + that will cause your test suite to fail for any unhandled native promise rejections. */ export function start(options = {}) { if (options.loadTests !== false) { @@ -259,6 +298,10 @@ export function start(options = {}) { setupEmberOnerrorValidation(); } + if (options.setupUnhandledRejectionHandler !== false) { + setupUnhandledRejectionHandler(); + } + if (options.startTests !== false) { startTests(); } diff --git a/tests/unit/unhandled-rejection-test.js b/tests/unit/unhandled-rejection-test.js new file mode 100644 index 00000000..7f8b8bae --- /dev/null +++ b/tests/unit/unhandled-rejection-test.js @@ -0,0 +1,94 @@ +import { __unhandledRejectionHandler__ } from 'ember-qunit'; +import { Promise as RSVPPromise } from 'rsvp'; +import { module, test } from 'qunit'; + +const HAS_NATIVE_PROMISE = typeof Promise !== 'undefined'; +const HAS_UNHANDLED_REJECTION_HANDLER = 'onunhandledrejection' in window; + +module('unhandle promise rejections', function(hooks) { + hooks.beforeEach(function(assert) { + let originalPushResult = assert.pushResult; + assert.pushResult = function(resultInfo) { + // Inverts the result so we can test failing assertions + resultInfo.result = !resultInfo.result; + resultInfo.message = `Failed: ${resultInfo.message}`; + originalPushResult(resultInfo); + }; + }); + + test('RSVP promises cause an unhandled rejection', function(assert) { + let done = assert.async(); + + // ensure we do not exit this test until the assertion has happened + setTimeout(done, 10); + + new RSVPPromise(resolve => { + setTimeout(resolve); + }).then(function() { + throw new Error('whoops!'); + }); + }); + + if (HAS_NATIVE_PROMISE && HAS_UNHANDLED_REJECTION_HANDLER) { + test('native promises cause an unhandled rejection', function(assert) { + let done = assert.async(); + + // ensure we do not exit this test until the assertion has happened + setTimeout(done, 10); + + new self.Promise(resolve => { + setTimeout(resolve); + }).then(function() { + throw new Error('whoops!'); + }); + }); + } +}); + +if (HAS_NATIVE_PROMISE && HAS_UNHANDLED_REJECTION_HANDLER) { + module('unhandled native promise rejection outside of test context', function(hooks) { + var originalPushResult; + + hooks.beforeEach(function(assert) { + // Duck-punch pushResult so we can check test name and assert args. + originalPushResult = assert.pushResult; + + assert.pushResult = function(resultInfo) { + // Restore pushResult for this assert object, to allow following assertions. + this.pushResult = originalPushResult; + + this.strictEqual(this.test.testName, 'global failure', 'Test is appropriately named'); + + this.deepEqual( + resultInfo, + { + message: 'Error message', + source: 'filePath.js:1', + result: false, + actual: { + message: 'Error message', + fileName: 'filePath.js', + lineNumber: 1, + stack: 'filePath.js:1', + }, + }, + 'Expected assert.pushResult to be called with correct args' + ); + }; + }); + + hooks.afterEach(function() { + QUnit.config.current.pushResult = originalPushResult; + }); + + // Actual test (outside QUnit.test context) + __unhandledRejectionHandler__({ + reason: { + message: 'Error message', + fileName: 'filePath.js', + lineNumber: 1, + stack: 'filePath.js:1', + }, + }); + }); +}