Skip to content

Commit c3ced39

Browse files
authored
delegate to Node on non-Mocha unhandled rejections (#4489)
This is not intended as a _fix_ for #4481, since it's possible that Mocha's behavior in v8.2.0 uncovers false positives. In other cases--depending on the use-case--they are not false positives at all, but rather annoyances that depended on the pre-v15 behavior of Node.js to only issue warnings. This PR changes the behavior of Mocha so that it will re-emit unhandled rejections to `process` _if_ they did not generate from Mocha. If the rejection generated from Mocha, then we treat it as an uncaught exception, because Mocha should not be in the business of ignoring its own unhandled rejections. The logic for detecting an "error originating from Mocha" is not exhaustive. Once an unhandled rejection is re-emitted to `process`, Node decides what to do with it based on how it is configured to handle unhandled rejections (strict, warn, quiet, etc.). Added a public method to `errors` module; `isMochaError()` Ref: #4481 Signed-off-by: Christopher Hiller <[email protected]>
1 parent fac181b commit c3ced39

File tree

5 files changed

+135
-24
lines changed

5 files changed

+135
-24
lines changed

lib/errors.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ var constants = {
129129
INVALID_PLUGIN_DEFINITION: 'ERR_MOCHA_INVALID_PLUGIN_DEFINITION'
130130
};
131131

132+
const MOCHA_ERRORS = new Set(Object.values(constants));
133+
132134
/**
133135
* Creates an error object to be thrown when no files to be tested could be found using specified pattern.
134136
*
@@ -419,6 +421,16 @@ function createInvalidPluginImplementationError(
419421
return err;
420422
}
421423

424+
/**
425+
* Returns `true` if an error came out of Mocha.
426+
* _Can suffer from false negatives, but not false positives._
427+
* @public
428+
* @param {*} err - Error, or anything
429+
* @returns {boolean}
430+
*/
431+
const isMochaError = err =>
432+
Boolean(err && typeof err === 'object' && MOCHA_ERRORS.has(err.code));
433+
422434
module.exports = {
423435
constants,
424436
createFatalError,
@@ -439,5 +451,6 @@ module.exports = {
439451
createNoFilesMatchPatternError,
440452
createUnsupportedError,
441453
deprecate,
454+
isMochaError,
442455
warn
443456
};

lib/runner.js

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ var sQuote = utils.sQuote;
2424
var stackFilter = utils.stackTraceFilter();
2525
var stringify = utils.stringify;
2626

27-
var errors = require('./errors');
28-
var createInvalidExceptionError = errors.createInvalidExceptionError;
29-
var createUnsupportedError = errors.createUnsupportedError;
30-
var createFatalError = errors.createFatalError;
27+
const {
28+
createInvalidExceptionError,
29+
createUnsupportedError,
30+
createFatalError,
31+
isMochaError,
32+
constants: errorConstants
33+
} = require('./errors');
3134

3235
/**
3336
* Non-enumerable globals.
@@ -179,6 +182,29 @@ class Runner extends EventEmitter {
179182
this.globals(this.globalProps());
180183

181184
this.uncaught = this._uncaught.bind(this);
185+
this.unhandled = (reason, promise) => {
186+
if (isMochaError(reason)) {
187+
debug(
188+
'trapped unhandled rejection coming out of Mocha; forwarding to uncaught handler:',
189+
reason
190+
);
191+
this.uncaught(reason);
192+
} else {
193+
debug(
194+
'trapped unhandled rejection from (probably) user code; re-emitting on process'
195+
);
196+
this._removeEventListener(
197+
process,
198+
'unhandledRejection',
199+
this.unhandled
200+
);
201+
try {
202+
process.emit('unhandledRejection', reason, promise);
203+
} finally {
204+
this._addEventListener(process, 'unhandledRejection', this.unhandled);
205+
}
206+
}
207+
};
182208
}
183209
}
184210

@@ -414,7 +440,7 @@ Runner.prototype.fail = function(test, err, force) {
414440
return;
415441
}
416442
if (this.state === constants.STATE_STOPPED) {
417-
if (err.code === errors.constants.MULTIPLE_DONE) {
443+
if (err.code === errorConstants.MULTIPLE_DONE) {
418444
throw err;
419445
}
420446
throw createFatalError(
@@ -1025,9 +1051,7 @@ Runner.prototype.run = function(fn, opts = {}) {
10251051
this.emit(constants.EVENT_RUN_BEGIN);
10261052
debug('run(): emitted %s', constants.EVENT_RUN_BEGIN);
10271053

1028-
this.runSuite(rootSuite, async () => {
1029-
end();
1030-
});
1054+
this.runSuite(rootSuite, end);
10311055
};
10321056

10331057
const prepare = () => {
@@ -1061,9 +1085,9 @@ Runner.prototype.run = function(fn, opts = {}) {
10611085
});
10621086

10631087
this._removeEventListener(process, 'uncaughtException', this.uncaught);
1064-
this._removeEventListener(process, 'unhandledRejection', this.uncaught);
1088+
this._removeEventListener(process, 'unhandledRejection', this.unhandled);
10651089
this._addEventListener(process, 'uncaughtException', this.uncaught);
1066-
this._addEventListener(process, 'unhandledRejection', this.uncaught);
1090+
this._addEventListener(process, 'unhandledRejection', this.unhandled);
10671091

10681092
if (this._delay) {
10691093
// for reporters, I guess.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
it('should emit an unhandled rejection', async function() {
2+
setTimeout(() => {
3+
Promise.resolve().then(() => {
4+
throw new Error('yikes');
5+
});
6+
});
7+
});

test/integration/uncaught.spec.js

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
'use strict';
22

3-
var helpers = require('./helpers');
4-
var run = helpers.runMochaJSON;
5-
var runMocha = helpers.runMocha;
6-
var invokeNode = helpers.invokeNode;
3+
const {
4+
runMocha,
5+
runMochaJSON: run,
6+
invokeMochaAsync,
7+
invokeNode,
8+
resolveFixturePath
9+
} = require('./helpers');
710
var args = [];
811

912
describe('uncaught exceptions', function() {
1013
it('handles uncaught exceptions from hooks', function(done) {
11-
run('uncaught/hook.fixture.js', args, function(err, res) {
14+
run('uncaught/hook', args, function(err, res) {
1215
if (err) {
1316
return done(err);
1417
}
@@ -24,7 +27,7 @@ describe('uncaught exceptions', function() {
2427
});
2528

2629
it('handles uncaught exceptions from async specs', function(done) {
27-
run('uncaught/double.fixture.js', args, function(err, res) {
30+
run('uncaught/double', args, function(err, res) {
2831
if (err) {
2932
return done(err);
3033
}
@@ -44,7 +47,7 @@ describe('uncaught exceptions', function() {
4447
});
4548

4649
it('handles uncaught exceptions from which Mocha cannot recover', function(done) {
47-
run('uncaught/fatal.fixture.js', args, function(err, res) {
50+
run('uncaught/fatal', args, function(err, res) {
4851
if (err) {
4952
return done(err);
5053
}
@@ -61,7 +64,7 @@ describe('uncaught exceptions', function() {
6164
});
6265

6366
it('handles uncaught exceptions within pending tests', function(done) {
64-
run('uncaught/pending.fixture.js', args, function(err, res) {
67+
run('uncaught/pending', args, function(err, res) {
6568
if (err) {
6669
return done(err);
6770
}
@@ -84,7 +87,7 @@ describe('uncaught exceptions', function() {
8487
});
8588

8689
it('handles uncaught exceptions within open tests', function(done) {
87-
run('uncaught/recover.fixture.js', args, function(err, res) {
90+
run('uncaught/recover', args, function(err, res) {
8891
if (err) {
8992
return done(err);
9093
}
@@ -111,8 +114,7 @@ describe('uncaught exceptions', function() {
111114
});
112115

113116
it('removes uncaught exceptions handlers correctly', function(done) {
114-
var path = require.resolve('./fixtures/uncaught/listeners.fixture.js');
115-
invokeNode([path], function(err, res) {
117+
invokeNode([resolveFixturePath('uncaught/listeners')], function(err, res) {
116118
if (err) {
117119
return done(err);
118120
}
@@ -124,7 +126,7 @@ describe('uncaught exceptions', function() {
124126

125127
it("handles uncaught exceptions after runner's end", function(done) {
126128
runMocha(
127-
'uncaught/after-runner.fixture.js',
129+
'uncaught/after-runner',
128130
args,
129131
function(err, res) {
130132
if (err) {
@@ -145,7 +147,7 @@ describe('uncaught exceptions', function() {
145147
});
146148

147149
it('issue-1327: should run the first test and then bail', function(done) {
148-
run('uncaught/issue-1327.fixture.js', args, function(err, res) {
150+
run('uncaught/issue-1327', args, function(err, res) {
149151
if (err) {
150152
return done(err);
151153
}
@@ -159,7 +161,7 @@ describe('uncaught exceptions', function() {
159161
});
160162

161163
it('issue-1417: uncaught exceptions from async specs', function(done) {
162-
run('uncaught/issue-1417.fixture.js', args, function(err, res) {
164+
run('uncaught/issue-1417', args, function(err, res) {
163165
if (err) {
164166
return done(err);
165167
}
@@ -174,4 +176,43 @@ describe('uncaught exceptions', function() {
174176
done();
175177
});
176178
});
179+
180+
describe('issue-4481: behavior of non-Mocha-originating unhandled rejections', function() {
181+
describe('when Node is in "warn" mode', function() {
182+
it('should warn', async function() {
183+
const [, promise] = invokeMochaAsync(
184+
[
185+
resolveFixturePath('uncaught/unhandled'),
186+
'--unhandled-rejections=warn'
187+
],
188+
{stdio: 'pipe'}
189+
);
190+
191+
return expect(
192+
promise,
193+
'when fulfilled',
194+
'to have passed with output',
195+
/UnhandledPromiseRejectionWarning: Error: yikes/
196+
);
197+
});
198+
});
199+
200+
describe('when Node is in "strict" mode', function() {
201+
it('should fail with an uncaught exception', async function() {
202+
const [, promise] = invokeMochaAsync(
203+
[
204+
resolveFixturePath('uncaught/unhandled'),
205+
'--unhandled-rejections=strict'
206+
],
207+
{stdio: 'pipe'}
208+
);
209+
return expect(
210+
promise,
211+
'when fulfilled',
212+
'to have failed with output',
213+
/Error: yikes/
214+
);
215+
});
216+
});
217+
});
177218
});

test/unit/errors.spec.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
var errors = require('../../lib/errors');
44
const sinon = require('sinon');
5+
const {createNoFilesMatchPatternError} = require('../../lib/errors');
56

67
describe('Errors', function() {
78
afterEach(function() {
@@ -143,4 +144,29 @@ describe('Errors', function() {
143144
expect(process.emitWarning, 'was not called');
144145
});
145146
});
147+
148+
describe('isMochaError()', function() {
149+
describe('when provided an Error object having a known Mocha error code', function() {
150+
it('should return true', function() {
151+
expect(
152+
errors.isMochaError(createNoFilesMatchPatternError('derp')),
153+
'to be true'
154+
);
155+
});
156+
});
157+
158+
describe('when provided an Error object with a non-Mocha error code', function() {
159+
it('should return false', function() {
160+
const err = new Error();
161+
err.code = 'ENOTEA';
162+
expect(errors.isMochaError(err), 'to be false');
163+
});
164+
});
165+
166+
describe('when provided a non-error', function() {
167+
it('should return false', function() {
168+
expect(errors.isMochaError(), 'to be false');
169+
});
170+
});
171+
});
146172
});

0 commit comments

Comments
 (0)