Skip to content

Commit d924045

Browse files
committed
Integrate with improved throws helper
Show instructions on how to use `t.throws()` with the test results, without writing them to stderr. Detect the improper usage even if user code swallows the error, meaning that tests will definitely fail. Assume that errors thrown, or emitted from an observable, or if the returned promise was rejected, that that error is due to the improper usage of `t.throws()`. Assume that if a test has a pending throws assertion, and an error leaks as an uncaught exception or an unhandled rejection, the error was thrown due to the pending throws assertion. Attribute it to the test.
1 parent 5e7ea9a commit d924045

19 files changed

+314
-71
lines changed
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
const chalk = require('chalk');
3+
4+
exports.forError = error => {
5+
if (!error.improperUsage) {
6+
return null;
7+
}
8+
9+
const assertion = error.assertion;
10+
if (assertion !== 'throws' || !assertion === 'notThrows') {
11+
return null;
12+
}
13+
14+
return `Try wrapping the first argument to \`t.${assertion}()\` in a function:
15+
16+
${chalk.cyan(`t.${assertion}(() => { `)}${chalk.grey('/* your code here */')}${chalk.cyan(' })')}
17+
18+
Visit the following URL for more details:
19+
20+
${chalk.blue.underline('https://github.com/avajs/ava#throwsfunctionpromise-error-message')}`;
21+
};

lib/reporters/mini.js

+6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const formatAssertError = require('../format-assert-error');
1212
const extractStack = require('../extract-stack');
1313
const codeExcerpt = require('../code-excerpt');
1414
const colors = require('../colors');
15+
const improperUsageMessages = require('./improper-usage-messages');
1516

1617
// TODO(@jamestalamge): This should be fixed in log-update and ansi-escapes once we are confident it's a good solution.
1718
const CSI = '\u001B[';
@@ -200,6 +201,11 @@ class MiniReporter {
200201
if (formatted) {
201202
status += '\n' + indentString(formatted, 2);
202203
}
204+
205+
const message = improperUsageMessages.forError(test.error);
206+
if (message) {
207+
status += '\n' + indentString(message, 2) + '\n';
208+
}
203209
}
204210

205211
if (test.error.stack) {

lib/reporters/verbose.js

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const formatAssertError = require('../format-assert-error');
88
const extractStack = require('../extract-stack');
99
const codeExcerpt = require('../code-excerpt');
1010
const colors = require('../colors');
11+
const improperUsageMessages = require('./improper-usage-messages');
1112

1213
class VerboseReporter {
1314
constructor(options) {
@@ -120,6 +121,11 @@ class VerboseReporter {
120121
if (formatted) {
121122
output += '\n' + indentString(formatted, 2);
122123
}
124+
125+
const message = improperUsageMessages.forError(test.error);
126+
if (message) {
127+
output += '\n' + indentString(message, 2) + '\n';
128+
}
123129
}
124130

125131
if (test.error.stack) {

lib/runner.js

+3
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ class Runner extends EventEmitter {
148148

149149
return Promise.resolve(this.tests.build().run()).then(this._buildStats);
150150
}
151+
attributeLeakedError(err) {
152+
return this.tests.attributeLeakedError(err);
153+
}
151154
}
152155

153156
optionChain(chainableMethods, function (opts, args) {

lib/test-collection.js

+19-4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class TestCollection extends EventEmitter {
2828
afterEachAlways: []
2929
};
3030

31+
this.pendingTestInstances = new Set();
32+
3133
this._emitTestResult = this._emitTestResult.bind(this);
3234
}
3335
add(test) {
@@ -100,8 +102,9 @@ class TestCollection extends EventEmitter {
100102
}
101103
};
102104
}
103-
_emitTestResult(test) {
104-
this.emit('test', test);
105+
_emitTestResult(result) {
106+
this.pendingTestInstances.delete(result.result);
107+
this.emit('test', result);
105108
}
106109
_buildHooks(hooks, testTitle, context) {
107110
return hooks.map(hook => {
@@ -125,28 +128,32 @@ class TestCollection extends EventEmitter {
125128
contextRef = null;
126129
}
127130

128-
return new Test({
131+
const test = new Test({
129132
contextRef,
130133
failWithoutAssertions: false,
131134
fn: hook.fn,
132135
metadata: hook.metadata,
133136
onResult: this._emitTestResult,
134137
title
135138
});
139+
this.pendingTestInstances.add(test);
140+
return test;
136141
}
137142
_buildTest(test, contextRef) {
138143
if (!contextRef) {
139144
contextRef = null;
140145
}
141146

142-
return new Test({
147+
test = new Test({
143148
contextRef,
144149
failWithoutAssertions: this.failWithoutAssertions,
145150
fn: test.fn,
146151
metadata: test.metadata,
147152
onResult: this._emitTestResult,
148153
title: test.title
149154
});
155+
this.pendingTestInstances.add(test);
156+
return test;
150157
}
151158
_buildTestWithHooks(test) {
152159
if (test.metadata.skipped || test.metadata.todo) {
@@ -183,6 +190,14 @@ class TestCollection extends EventEmitter {
183190
}
184191
return finalTests;
185192
}
193+
attributeLeakedError(err) {
194+
for (const test of this.pendingTestInstances) {
195+
if (test.attributeLeakedError(err)) {
196+
return true;
197+
}
198+
}
199+
return false;
200+
}
186201
}
187202

188203
module.exports = TestCollection;

lib/test-worker.js

+19-4
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ const isObj = require('is-obj');
2424
const adapter = require('./process-adapter');
2525
const globals = require('./globals');
2626
const serializeError = require('./serialize-error');
27-
const throwsHelper = require('./throws-helper');
2827

2928
const opts = adapter.opts;
3029
const testPath = opts.file;
@@ -54,10 +53,25 @@ if (!runner) {
5453
adapter.send('no-tests', {avaRequired: false});
5554
}
5655

57-
process.on('unhandledRejection', throwsHelper);
56+
function attributeLeakedError(err) {
57+
if (!runner) {
58+
return false;
59+
}
60+
61+
return runner.attributeLeakedError(err);
62+
}
63+
64+
const attributedRejections = new Set();
65+
process.on('unhandledRejection', (reason, promise) => {
66+
if (attributeLeakedError(reason)) {
67+
attributedRejections.add(promise);
68+
}
69+
});
5870

5971
process.on('uncaughtException', exception => {
60-
throwsHelper(exception);
72+
if (attributeLeakedError(exception)) {
73+
return;
74+
}
6175

6276
let serialized;
6377
try {
@@ -88,7 +102,8 @@ process.on('ava-teardown', () => {
88102
}
89103
tearingDown = true;
90104

91-
let rejections = currentlyUnhandled();
105+
let rejections = currentlyUnhandled()
106+
.filter(rejection => !attributedRejections.has(rejection.promise));
92107

93108
if (rejections.length > 0) {
94109
rejections = rejections.map(rejection => {

lib/test.js

+90-15
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const plur = require('plur');
88
const assert = require('./assert');
99
const formatAssertError = require('./format-assert-error');
1010
const globals = require('./globals');
11-
const throwsHelper = require('./throws-helper');
1211

1312
class SkipApi {
1413
constructor(test) {
@@ -60,6 +59,13 @@ class ExecutionContext {
6059

6160
contextRef.context = context;
6261
}
62+
63+
_throwsArgStart(assertion, file, line) {
64+
this._test.trackThrows({assertion, file, line});
65+
}
66+
_throwsArgEnd() {
67+
this._test.trackThrows(null);
68+
}
6369
}
6470
Object.defineProperty(ExecutionContext.prototype, 'context', {enumerable: true});
6571

@@ -101,9 +107,11 @@ class Test {
101107
this.calledEnd = false;
102108
this.duration = null;
103109
this.endCallbackFinisher = null;
110+
this.finishDueToAttributedError = null;
104111
this.finishDueToInactivity = null;
105112
this.finishing = false;
106113
this.pendingAssertions = [];
114+
this.pendingThrowsAssertion = null;
107115
this.planCount = null;
108116
this.startedAt = 0;
109117
}
@@ -204,6 +212,60 @@ class Test {
204212
}
205213
}
206214

215+
trackThrows(pending) {
216+
this.pendingThrowsAssertion = pending;
217+
}
218+
219+
detectImproperThrows(err) {
220+
if (!this.pendingThrowsAssertion) {
221+
return false;
222+
}
223+
224+
const pending = this.pendingThrowsAssertion;
225+
this.pendingThrowsAssertion = null;
226+
227+
const values = [];
228+
if (err) {
229+
values.push(formatAssertError.formatWithLabel(`The following error was thrown, possibly before \`t.${pending.assertion}()\` could be called:`, err));
230+
}
231+
232+
this.saveFirstError(new assert.AssertionError({
233+
assertion: pending.assertion,
234+
fixedSource: {file: pending.file, line: pending.line},
235+
improperUsage: true,
236+
message: `Improper usage of \`t.${pending.assertion}()\` detected`,
237+
stack: err instanceof Error && err.stack,
238+
values
239+
}));
240+
return true;
241+
}
242+
243+
waitForPendingThrowsAssertion() {
244+
return new Promise(resolve => {
245+
this.finishDueToAttributedError = () => {
246+
resolve(this.finishPromised());
247+
};
248+
249+
this.finishDueToInactivity = () => {
250+
this.detectImproperThrows();
251+
resolve(this.finishPromised());
252+
};
253+
254+
// Wait up to a second to see if an error can be attributed to the
255+
// pending assertion.
256+
globals.setTimeout(() => this.finishDueToInactivity(), 1000).unref();
257+
});
258+
}
259+
260+
attributeLeakedError(err) {
261+
if (!this.detectImproperThrows(err)) {
262+
return false;
263+
}
264+
265+
this.finishDueToAttributedError();
266+
return true;
267+
}
268+
207269
callFn() {
208270
try {
209271
return {
@@ -223,13 +285,13 @@ class Test {
223285

224286
const result = this.callFn();
225287
if (!result.ok) {
226-
throwsHelper(result.error);
227-
228-
this.saveFirstError(new assert.AssertionError({
229-
message: 'Error thrown in test',
230-
stack: result.error instanceof Error && result.error.stack,
231-
values: [formatAssertError.formatWithLabel('Error:', result.error)]
232-
}));
288+
if (!this.detectImproperThrows(result.error)) {
289+
this.saveFirstError(new assert.AssertionError({
290+
message: 'Error thrown in test',
291+
stack: result.error instanceof Error && result.error.stack,
292+
values: [formatAssertError.formatWithLabel('Error:', result.error)]
293+
}));
294+
}
233295
return this.finish();
234296
}
235297

@@ -260,6 +322,10 @@ class Test {
260322
resolve(this.finishPromised());
261323
};
262324

325+
this.finishDueToAttributedError = () => {
326+
resolve(this.finishPromised());
327+
};
328+
263329
this.finishDueToInactivity = () => {
264330
this.saveFirstError(new Error('`t.end()` was never called'));
265331
resolve(this.finishPromised());
@@ -269,6 +335,10 @@ class Test {
269335

270336
if (promise) {
271337
return new Promise(resolve => {
338+
this.finishDueToAttributedError = () => {
339+
resolve(this.finishPromised());
340+
};
341+
272342
this.finishDueToInactivity = () => {
273343
const err = returnedObservable ?
274344
new Error('Observable returned by test never completed') :
@@ -279,13 +349,13 @@ class Test {
279349

280350
promise
281351
.catch(err => {
282-
throwsHelper(err);
283-
284-
this.saveFirstError(new assert.AssertionError({
285-
message: 'Rejected promise returned by test',
286-
stack: err instanceof Error && err.stack,
287-
values: [formatAssertError.formatWithLabel('Rejection reason:', err)]
288-
}));
352+
if (!this.detectImproperThrows(err)) {
353+
this.saveFirstError(new assert.AssertionError({
354+
message: 'Rejected promise returned by test',
355+
stack: err instanceof Error && err.stack,
356+
values: [formatAssertError.formatWithLabel('Rejection reason:', err)]
357+
}));
358+
}
289359
})
290360
.then(() => resolve(this.finishPromised()));
291361
});
@@ -296,6 +366,11 @@ class Test {
296366

297367
finish() {
298368
this.finishing = true;
369+
370+
if (!this.assertError && this.pendingThrowsAssertion) {
371+
return this.waitForPendingThrowsAssertion();
372+
}
373+
299374
this.verifyPlan();
300375
this.verifyAssertions();
301376

lib/throws-helper.js

-37
This file was deleted.

0 commit comments

Comments
 (0)