diff --git a/bin/run.js b/bin/run.js index 6a627d653..6125225da 100644 --- a/bin/run.js +++ b/bin/run.js @@ -33,6 +33,11 @@ function run( args, options ) { } } ); + // Listen for unhandled rejections, and call QUnit.onUnhandledRejection + process.on( "unhandledRejection", function( reason ) { + QUnit.onUnhandledRejection( reason ); + } ); + const files = utils.getFilesFromArgs( args ); QUnit = requireQUnit(); diff --git a/reporter/html.js b/reporter/html.js index aeeb8a0a4..ecc3277c0 100644 --- a/reporter/html.js +++ b/reporter/html.js @@ -980,4 +980,8 @@ export function escapeText( s ) { return ret; }; + // Listen for unhandled rejections, and call QUnit.onUnhandledRejection + window.addEventListener( "unhandledrejection", function( event ) { + QUnit.onUnhandledRejection( event.reason ); + } ); }() ); diff --git a/src/core.js b/src/core.js index b4c76ab71..7dd2c11b9 100644 --- a/src/core.js +++ b/src/core.js @@ -16,6 +16,7 @@ import SuiteReport from "./reports/suite"; import { on, emit } from "./events"; import onError from "./core/onerror"; +import onUnhandledRejection from "./core/on-unhandled-rejection"; let focused = false; const QUnit = {}; @@ -251,7 +252,9 @@ extend( QUnit, { return sourceFromStacktrace( offset ); }, - onError + onError, + + onUnhandledRejection } ); QUnit.pushFailure = pushFailure; diff --git a/src/core/on-unhandled-rejection.js b/src/core/on-unhandled-rejection.js new file mode 100644 index 000000000..458ca4f79 --- /dev/null +++ b/src/core/on-unhandled-rejection.js @@ -0,0 +1,25 @@ +import { test } from "../test"; + +import config from "./config"; +import { extend } from "./utilities"; +import { sourceFromStacktrace } from "./stacktrace"; + +// Handle an unhandled rejection +export default function onUnhandledRejection( reason ) { + const resultInfo = { + result: false, + message: reason.message || "error", + actual: reason, + source: reason.stack || sourceFromStacktrace( 3 ) + }; + + const currentTest = config.current; + if ( currentTest ) { + currentTest.assert.pushResult( resultInfo ); + } else { + test( "global failure", extend( function( assert ) { + assert.pushResult( resultInfo ); + }, { validTest: true } ) ); + } +} + diff --git a/test/cli/fixtures/expected/tap-outputs.js b/test/cli/fixtures/expected/tap-outputs.js index 435ca6a94..ff3c5cd7e 100644 --- a/test/cli/fixtures/expected/tap-outputs.js +++ b/test/cli/fixtures/expected/tap-outputs.js @@ -1,3 +1,5 @@ +"use strict"; + // Expected outputs from the TapReporter for the commands run in CLI tests module.exports = { "qunit": @@ -89,6 +91,38 @@ Available custom reporters from dependencies are: npm-reporter /* eslint-disable max-len */ "qunit hanging-test": `Error: Process exited before tests finished running Last test to run (hanging) has an async hold. Ensure all assert.async() callbacks are invoked and Promises resolve. You should also set a standard timeout via QUnit.config.testTimeout. -` +`, /* eslint-enable max-len */ + "qunit unhandled-rejection.js": +`TAP version 13 +not ok 1 Unhandled Rejections > test passes just fine, but has a rejected promise + --- + message: "Error thrown in non-returned promise!" + severity: failed + actual: { + "message": "Error thrown in non-returned promise!", + "stack": "Error: Error thrown in non-returned promise!\\n at /some/path/wherever/unhandled-rejection.js:13:11" +} + expected: undefined + stack: Error: Error thrown in non-returned promise! + at /some/path/wherever/unhandled-rejection.js:13:11 + ... +not ok 2 global failure + --- + message: "outside of a test context" + severity: failed + actual: { + "message": "outside of a test context", + "stack": "Error: outside of a test context\\n at Object. (/some/path/wherever/unhandled-rejection.js:20:18)" +} + expected: undefined + stack: Error: outside of a test context + at Object. (/some/path/wherever/unhandled-rejection.js:20:18) + ... +1..2 +# pass 0 +# skip 0 +# todo 0 +# fail 2 +` }; diff --git a/test/cli/fixtures/unhandled-rejection.js b/test/cli/fixtures/unhandled-rejection.js new file mode 100644 index 000000000..4846a8925 --- /dev/null +++ b/test/cli/fixtures/unhandled-rejection.js @@ -0,0 +1,31 @@ +"use strict"; + +QUnit.module( "Unhandled Rejections", function() { + QUnit.test( "test passes just fine, but has a rejected promise", function( assert ) { + assert.ok( true ); + + const done = assert.async(); + + Promise.resolve().then( function() { + + // throwing a non-Error here because stack trace representation + // across Node versions is not stable (they continue to get better) + throw { + message: "Error thrown in non-returned promise!", + stack: `Error: Error thrown in non-returned promise! + at /some/path/wherever/unhandled-rejection.js:13:11` + }; + } ); + + // prevent test from exiting before unhandled rejection fires + setTimeout( done, 10 ); + } ); + + // rejecting with a non-Error here because stack trace representation + // across Node versions is not stable (they continue to get better) + Promise.reject( { + message: "outside of a test context", + stack: `Error: outside of a test context + at Object. (/some/path/wherever/unhandled-rejection.js:20:18)` + } ); +} ); diff --git a/test/cli/main.js b/test/cli/main.js index 394dd1ee0..6e153199f 100644 --- a/test/cli/main.js +++ b/test/cli/main.js @@ -105,6 +105,22 @@ QUnit.module( "CLI Main", function() { } } ) ); + QUnit.test( "unhandled rejections fail tests", co.wrap( function* ( assert ) { + const command = "qunit unhandled-rejection.js"; + + try { + const result = yield execute( command ); + assert.pushResult( { + result: false, + actual: result.stdout + } ); + } catch ( e ) { + assert.equal( e.code, 1 ); + assert.equal( e.stderr, "" ); + assert.equal( e.stdout, expectedOutput[ command ] ); + } + } ) ); + QUnit.module( "filter", function() { QUnit.test( "can properly filter tests", co.wrap( function* ( assert ) { const command = "qunit --filter 'single' test single.js 'glob/**/*-test.js'"; diff --git a/test/index.html b/test/index.html index 64b4024b2..36bb9f3da 100644 --- a/test/index.html +++ b/test/index.html @@ -19,6 +19,7 @@ + diff --git a/test/reporter-html/unhandled-rejection.js b/test/reporter-html/unhandled-rejection.js new file mode 100644 index 000000000..5205e2e8e --- /dev/null +++ b/test/reporter-html/unhandled-rejection.js @@ -0,0 +1,75 @@ +// Detect if the current browser supports `onunhandledrejection` (avoiding +// errors for browsers without the capability) +var HAS_UNHANDLED_REJECTION_HANDLER = "onunhandledrejection" in window; + +if ( HAS_UNHANDLED_REJECTION_HANDLER ) { + QUnit.module( "Unhandled Rejections inside test context", function( hooks ) { + hooks.beforeEach( function( assert ) { + var originalPushResult = assert.pushResult; + assert.pushResult = function( resultInfo ) { + + // Inverts the result so we can test failing assertions + resultInfo.result = !resultInfo.result; + originalPushResult( resultInfo ); + }; + } ); + + QUnit.test( "test passes just fine, but has a rejected promise", function( assert ) { + const done = assert.async(); + + Promise.resolve().then( function() { + throw new Error( "Error thrown in non-returned promise!" ); + } ); + + // prevent test from exiting before unhandled rejection fires + setTimeout( done, 10 ); + } ); + + } ); + + QUnit.module( "Unhandled Rejections outside 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) + QUnit.onUnhandledRejection( { + message: "Error message", + fileName: "filePath.js", + lineNumber: 1, + stack: "filePath.js:1" + } ); + } ); +}