Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core: New error event for bailing on uncaught errors #1638

Merged
merged 3 commits into from
Jul 28, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ module.exports = function( grunt ) {
"test/reporter-urlparams-hidepassed.html",
"test/moduleId.html",
"test/onerror/inside-test.html",
"test/onerror/outside-test.html",
"test/seed.html",
"test/overload.html",
"test/preconfigured.html",
Expand Down Expand Up @@ -125,9 +124,9 @@ module.exports = function( grunt ) {
"test/main/deepEqual.js",
"test/main/stack.js",
"test/main/utilities.js",
"test/main/onUncaughtException.js",
"test/events-in-test.js",
"test/onerror/inside-test.js",
"test/onerror/outside-test.js",
"test/setTimeout.js",
"test/node/storage-1.js",
"test/node/storage-2.js",
Expand Down
24 changes: 9 additions & 15 deletions src/cli/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,25 +95,12 @@ async function run( args, options ) {
}
}

// The below handlers set exitCode directly, to make sure it is set even if the
// uncaught exception happens after the last test (shortly before "runEnd", or
// asynchronously after "runEnd" if the process is still running).
//
// The variable makes sure that if the uncaught exception is before "runEnd",
// or from another "runEnd" callback, it also won't turn the error code
// back into a success.
let uncaught = false;

// Handle the unhandled
process.on( "unhandledRejection", ( reason, _promise ) => {
QUnit.onUncaughtException( reason );
process.exitCode = 1;
uncaught = true;
} );
process.on( "uncaughtException", ( error, _origin ) => {
QUnit.onUncaughtException( error );
process.exitCode = 1;
uncaught = true;
} );

let running = true;
Expand All @@ -131,17 +118,24 @@ async function run( args, options ) {
}
} );

QUnit.start();
QUnit.on( "error", function( _error ) {

// Set exitCode directly, to make sure it is set to fail even if "runEnd" will never be
// reached, or if "runEnd" was already fired in the past and the process crashed later.
process.exitCode = 1;
} );

QUnit.on( "runEnd", function setExitCode( data ) {
running = false;

if ( data.testCounts.failed || uncaught ) {
if ( data.testCounts.failed ) {
process.exitCode = 1;
} else {
process.exitCode = 0;
}
} );

QUnit.start();
}

run.restart = function( args ) {
Expand Down
1 change: 0 additions & 1 deletion src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ extend( QUnit, {

// Initialize the configuration options
extend( config, {
stats: { all: 0, bad: 0, testCount: 0 },
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncaught errors now increment these. As errors can happen before/during begin(), I had to move this to config.js as the increment would fail otherwise with the stats object being undefined. We can move more of this over, but that's for another day.

started: 0,
updateRate: 1000,
autostart: true,
Expand Down
2 changes: 2 additions & 0 deletions src/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const config = {
// The queue of tests to run
queue: [],

stats: { all: 0, bad: 0, testCount: 0 },

// Block until document ready
blocking: true,

Expand Down
64 changes: 30 additions & 34 deletions src/core/on-uncaught-exception.js
Original file line number Diff line number Diff line change
@@ -1,55 +1,51 @@
import config from "./config";
import ProcessingQueue from "./processing-queue";
import { globalSuite } from "../core";
import { sourceFromStacktrace } from "./stacktrace";
import { extend, errorString } from "./utilities";
import Logger from "../logger";
import { test } from "../test";
import { errorString } from "./utilities";
import { emit } from "../events";

/**
* Handle a global error that should result in a failed test run.
*
* Summary:
*
* - If there is a current test, it becomes a failed assertion.
* - If there is a current module, it becomes a failed test (and bypassing filters).
* Note that if we're before any other test or module, it will naturally
* become a global test.
* - If the overall test run has ended, the error is printed to `console.warn()`.
* - If we're strictly inside a test (or one if its module hooks), the exception
* becomes a failed assertion.
*
* This has the important side-effect that uncaught exceptions (such as
* calling an undefined function) during a "todo" test do NOT result in
* a failed test run.
*
* - If we're anywhere outside a test (be it in early event callbacks, or
* internally between tests, or somewhere after "runEnd" if the process is
* still alive for some reason), then send an "error" event to the reporters.
*
* @since 2.17.0
* @param {Error|any} error
*/
export default function onUncaughtException( error ) {
const message = errorString( error );

// We could let callers specify an extra offset to add to the number passed to
// sourceFromStacktrace, in case they are a wrapper further away from the error
// handler, and thus reduce some noise in the stack trace. However, we're not
// doing this right now because it would almost never be used in practice given
// the vast majority of error values will be an Error object, and thus have a
// clean stack trace already.
const source = error.stack || sourceFromStacktrace( 2 );

if ( config.current ) {
config.current.assert.pushResult( {
result: false,
message: `global failure: ${message}`,
source
message: `global failure: ${errorString( error )}`,

// We could let callers specify an offset to substract a number of frames via
Krinkle marked this conversation as resolved.
Show resolved Hide resolved
// sourceFromStacktrace, in case they are a wrapper further away from the error
// handler, and thus reduce some noise in the stack trace. However, we're not
// doing this right now because it would almost never be used in practice given
// the vast majority of error values will be Error objects, and thus have their
// own stack trace already.
source: ( error && error.stack ) || sourceFromStacktrace( 2 )
} );
} else if ( !ProcessingQueue.finished ) {
test( "global failure", extend( function( assert ) {
assert.pushResult( { result: false, message, source } );
}, { validTest: true } ) );
} else {

// TODO: Once supported in js-reporters and QUnit, use a TAP "bail" event.
// The CLI runner can use this to ensure a non-zero exit code, even if
// emitted after "runEnd" and before the process exits.
// The HTML Reporter can use this to renmder it on the page in a test-like
// block for easy discovery.
//
// Avoid printing "Error: foo" twice if the environment's native stack trace
// already includes that in its format.
Logger.warn( source.indexOf( source ) !== -1 ? source : `${message}\n${source}` );
// The "error" event was added in QUnit 2.17.
// Increase "bad assertion" stats despite no longer pushing an assertion in this case.
// This ensures "runEnd" and "QUnit.done()" handlers behave as expected, since the "bad"
// count is typically how reporters decide on the boolean outcome of the test run.
globalSuite.globalFailureCount++;
config.stats.bad++;
config.stats.all++;
emit( "error", error );
}
}
4 changes: 4 additions & 0 deletions src/core/onerror.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Logger from "../logger";
import config from "./config";
import onUncaughtException from "./on-uncaught-exception";

Expand All @@ -13,6 +14,7 @@ import onUncaughtException from "./on-uncaught-exception";
* to receive an "expected" error during `assert.throws()`.
*
* @see <https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onerror>
* @deprecated since 2.17.0
* @param {Object} details
* @param {string} details.message
* @param {string} details.fileName
Expand All @@ -21,6 +23,8 @@ import onUncaughtException from "./on-uncaught-exception";
* @return {bool} True if native error reporting should be suppressed.
*/
export default function onWindowError( details ) {
Logger.warn( "QUnit.onError is deprecated and will be removed in QUnit 3.0." );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a suggested fix?
Use QUnit.on('error', ...)?

Copy link
Member Author

@Krinkle Krinkle Jul 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I did not provide a direct replacement. This was an internal method in my mind, with a rather awkward/incompete responsibility. It wasn't exactly a window.onerror callback, because it takes a custom object that one would have to fill in on the caller side, and we also need additional logic as we have in our actual window.onerror callback in html.js before callling this function. It also wasn't exactly a "push global failure" utility.

As part of 07de3c6, I factored out the part I think is re-usable, into QUnit.onUnhandledRejection(), and documented it. And in this PR, I copied the remaining logic from here to our actual window handler in html.js.

If someone out there did end up using this, as part of a custom HTML Reporter or something, and consumed this method, then a replacement would involve calling onUncaughtException(), plus whatever else this method currently does. I don't expect it to be likely, but it's something worth clarifying in the warning message, and to call out in the upcoming migration guide.

Speaking of migration guide, I've made a note of that at #1498.

TODO: Improve console warning message.


if ( config.current && config.current.ignoreGlobalErrors ) {
return true;
}
Expand Down
15 changes: 5 additions & 10 deletions src/core/processing-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,11 @@ function done() {
const runtime = now() - config.started;
const passed = config.stats.all - config.stats.bad;

// We have reached the end of the processing queue, but there is one more
// thing we'd like to report as a possible failing test. For this to work
// we need to call `onUncaughtException` before closing `ProcessingQueue.finished`.
// However, we do need to call `advance()` in order to resume the processing queue.
// Once this new test is finished processing, we'll reach `done` again, and
// that time the below condition should evaluate to false.
ProcessingQueue.finished = true;

// We have reached the end of the processing queue and are about to emit the
// "runEnd" event after which reporters typically stop listening and exit
// the process. First, check if we need to emit one final error.
if ( config.stats.testCount === 0 && config.failOnZeroTests === true ) {
let error;
if ( config.filter && config.filter.length ) {
Expand All @@ -184,12 +183,8 @@ function done() {
}

onUncaughtException( error );
advance();
return;
}

ProcessingQueue.finished = true;

emit( "runEnd", globalSuite.end( true ) );
runLoggingCallbacks( "done", {
passed,
Expand Down
1 change: 1 addition & 0 deletions src/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { objectType, inArray } from "./core/utilities";

const LISTENERS = Object.create( null );
const SUPPORTED_EVENTS = [
"error",
"runStart",
"suiteStart",
"testStart",
Expand Down
74 changes: 57 additions & 17 deletions src/html-reporter/html.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import QUnit from "../core";
import Test from "../test";
import { extractStacktrace } from "../core/stacktrace";
import { now, extend } from "../core/utilities";
import { now, extend, errorString } from "../core/utilities";
import { window, document, navigator, console } from "../globals";
import "./urlparams";
import fuzzysort from "fuzzysort";

// TODO: Remove adhoc counting in favour of stats from QUnit.done() or "runEnd" event.
const stats = {
passedTests: 0,
failedTests: 0,
Expand Down Expand Up @@ -654,21 +654,27 @@ export function escapeText( s ) {
title = document.createElement( "strong" );
title.innerHTML = getNameHtml( name, moduleName );

rerunTrigger = document.createElement( "a" );
rerunTrigger.innerHTML = "Rerun";
rerunTrigger.href = setUrl( { testId: testId } );

testBlock = document.createElement( "li" );
testBlock.appendChild( title );
testBlock.appendChild( rerunTrigger );
testBlock.id = "qunit-test-output-" + testId;

// No ID or rerun link for "global failure" blocks
if ( testId !== undefined ) {
rerunTrigger = document.createElement( "a" );
rerunTrigger.innerHTML = "Rerun";
rerunTrigger.href = setUrl( { testId: testId } );

testBlock.id = "qunit-test-output-" + testId;
testBlock.appendChild( rerunTrigger );
}

assertList = document.createElement( "ol" );
assertList.className = "qunit-assert-list";

testBlock.appendChild( assertList );

tests.appendChild( testBlock );

return testBlock;
}

// HTML Reporter initialization and load
Expand Down Expand Up @@ -1027,6 +1033,34 @@ export function escapeText( s ) {
}
} );

QUnit.on( "error", ( error ) => {
stats.failedTests++;

const testItem = appendTest( "global failure" );
if ( !testItem ) {

// HTML Reporter is probably disabled or not yet initialized.
console.warn( "global failure" );
console.warn( error );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warn vs error? I'm good with either, but maybe I was surprised to find this wasn't console.error; curious if that was intentional

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's an awkward fit, though note that we did also use warn() in onUncaughtException previously, for the same purpose (see left/red part of the diff).

This was a compromise to keep the diff minimal because we don't actually interface yet with console.error() at all. (Though I agree that we should!) It's be a bit more work to add it to our cross-realm interface and then adopt it in the places where it makes sense.

return;
}

// Render similar to a failed assertion (see above QUnit.log callback)
let message = escapeText( errorString( error ) );
message = "<span class='test-message'>" + message + "</span>";
if ( error && error.stack ) {
message += "<table>" +
"<tr class='test-source'><th>Source: </th><td><pre>" +
escapeText( error.stack ) + "</pre></td></tr>" +
"</table>";
}
const assertList = testItem.getElementsByTagName( "ol" )[ 0 ];
const assertLi = document.createElement( "li" );
assertLi.className = "fail";
assertLi.innerHTML = message;
assertList.appendChild( assertLi );
} );

// Avoid readyState issue with phantomjs
// Ref: #818
var usingPhantom = ( function( p ) {
Expand Down Expand Up @@ -1068,21 +1102,27 @@ export function escapeText( s ) {
// Treat return value as window.onerror itself does,
// Only do our handling if not suppressed.
if ( ret !== true ) {
const error = {
message,
fileName,
lineNumber
};

// If there is a current test that sets the internal `ignoreGlobalErrors` field
// (such as during `assert.throws()`), then the error is ignored and native
// error reporting is suppressed as well. This is because in browsers, an error
// can sometimes end up in `window.onerror` instead of in the local try/catch.
// This ignoring of errors does not apply to our general onUncaughtException
// method, nor to our `unhandledRejection` handlers, as those are not meant
// to receive an "expected" error during `assert.throws()`.
if ( config.current && config.current.ignoreGlobalErrors ) {
return true;
}

// According to
// https://blog.sentry.io/2016/01/04/client-javascript-reporting-window-onerror,
// most modern browsers support an errorObj argument; use that to
// get a full stack trace if it's available.
if ( errorObj && errorObj.stack ) {
error.stacktrace = extractStacktrace( errorObj, 0 );
const error = errorObj || new Error( message );
if ( !error.stack && fileName && lineNumber ) {
error.stack = `${fileName}:${lineNumber}`;
}

ret = QUnit.onError( error );
QUnit.onUncaughtException( error );
}

return ret;
Expand Down
5 changes: 5 additions & 0 deletions src/reporters/ConsoleReporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default class ConsoleReporter {
// Support IE 9: Function#bind is supported, but no console.log.bind().
this.log = options.log || Function.prototype.bind.call( console.log, console );

runner.on( "error", this.onError.bind( this ) );
runner.on( "runStart", this.onRunStart.bind( this ) );
runner.on( "testStart", this.onTestStart.bind( this ) );
runner.on( "testEnd", this.onTestEnd.bind( this ) );
Expand All @@ -19,6 +20,10 @@ export default class ConsoleReporter {
return new ConsoleReporter( runner, options );
}

onError( error ) {
this.log( "error", error );
}

onRunStart( runStart ) {
this.log( "runStart", runStart );
}
Expand Down
Loading