UNPKG

10.8 kBJavaScriptView Raw
1'use strict';
2
3var EventEmitter = require('events').EventEmitter;
4var Pending = require('./pending');
5var debug = require('debug')('mocha:runnable');
6var milliseconds = require('ms');
7var utils = require('./utils');
8var errors = require('./errors');
9var createInvalidExceptionError = errors.createInvalidExceptionError;
10var createMultipleDoneError = errors.createMultipleDoneError;
11
12/**
13 * Save timer references to avoid Sinon interfering (see GH-237).
14 */
15var Date = global.Date;
16var setTimeout = global.setTimeout;
17var clearTimeout = global.clearTimeout;
18var toString = Object.prototype.toString;
19
20module.exports = Runnable;
21
22/**
23 * Initialize a new `Runnable` with the given `title` and callback `fn`.
24 *
25 * @class
26 * @extends external:EventEmitter
27 * @public
28 * @param {String} title
29 * @param {Function} fn
30 */
31function Runnable(title, fn) {
32 this.title = title;
33 this.fn = fn;
34 this.body = (fn || '').toString();
35 this.async = fn && fn.length;
36 this.sync = !this.async;
37 this._timeout = 2000;
38 this._slow = 75;
39 this._enableTimeouts = true;
40 this._retries = -1;
41 this.reset();
42}
43
44/**
45 * Inherit from `EventEmitter.prototype`.
46 */
47utils.inherits(Runnable, EventEmitter);
48
49/**
50 * Resets the state initially or for a next run.
51 */
52Runnable.prototype.reset = function() {
53 this.timedOut = false;
54 this._currentRetry = 0;
55 this.pending = false;
56 delete this.state;
57 delete this.err;
58};
59
60/**
61 * Get current timeout value in msecs.
62 *
63 * @private
64 * @returns {number} current timeout threshold value
65 */
66/**
67 * @summary
68 * Set timeout threshold value (msecs).
69 *
70 * @description
71 * A string argument can use shorthand (e.g., "2s") and will be converted.
72 * The value will be clamped to range [<code>0</code>, <code>2^<sup>31</sup>-1</code>].
73 * If clamped value matches either range endpoint, timeouts will be disabled.
74 *
75 * @private
76 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#Maximum_delay_value}
77 * @param {number|string} ms - Timeout threshold value.
78 * @returns {Runnable} this
79 * @chainable
80 */
81Runnable.prototype.timeout = function(ms) {
82 if (!arguments.length) {
83 return this._timeout;
84 }
85 if (typeof ms === 'string') {
86 ms = milliseconds(ms);
87 }
88
89 // Clamp to range
90 var INT_MAX = Math.pow(2, 31) - 1;
91 var range = [0, INT_MAX];
92 ms = utils.clamp(ms, range);
93
94 // see #1652 for reasoning
95 if (ms === range[0] || ms === range[1]) {
96 this._enableTimeouts = false;
97 }
98 debug('timeout %d', ms);
99 this._timeout = ms;
100 if (this.timer) {
101 this.resetTimeout();
102 }
103 return this;
104};
105
106/**
107 * Set or get slow `ms`.
108 *
109 * @private
110 * @param {number|string} ms
111 * @return {Runnable|number} ms or Runnable instance.
112 */
113Runnable.prototype.slow = function(ms) {
114 if (!arguments.length || typeof ms === 'undefined') {
115 return this._slow;
116 }
117 if (typeof ms === 'string') {
118 ms = milliseconds(ms);
119 }
120 debug('slow %d', ms);
121 this._slow = ms;
122 return this;
123};
124
125/**
126 * Set and get whether timeout is `enabled`.
127 *
128 * @private
129 * @param {boolean} enabled
130 * @return {Runnable|boolean} enabled or Runnable instance.
131 */
132Runnable.prototype.enableTimeouts = function(enabled) {
133 if (!arguments.length) {
134 return this._enableTimeouts;
135 }
136 debug('enableTimeouts %s', enabled);
137 this._enableTimeouts = enabled;
138 return this;
139};
140
141/**
142 * Halt and mark as pending.
143 *
144 * @memberof Mocha.Runnable
145 * @public
146 */
147Runnable.prototype.skip = function() {
148 this.pending = true;
149 throw new Pending('sync skip; aborting execution');
150};
151
152/**
153 * Check if this runnable or its parent suite is marked as pending.
154 *
155 * @private
156 */
157Runnable.prototype.isPending = function() {
158 return this.pending || (this.parent && this.parent.isPending());
159};
160
161/**
162 * Return `true` if this Runnable has failed.
163 * @return {boolean}
164 * @private
165 */
166Runnable.prototype.isFailed = function() {
167 return !this.isPending() && this.state === constants.STATE_FAILED;
168};
169
170/**
171 * Return `true` if this Runnable has passed.
172 * @return {boolean}
173 * @private
174 */
175Runnable.prototype.isPassed = function() {
176 return !this.isPending() && this.state === constants.STATE_PASSED;
177};
178
179/**
180 * Set or get number of retries.
181 *
182 * @private
183 */
184Runnable.prototype.retries = function(n) {
185 if (!arguments.length) {
186 return this._retries;
187 }
188 this._retries = n;
189};
190
191/**
192 * Set or get current retry
193 *
194 * @private
195 */
196Runnable.prototype.currentRetry = function(n) {
197 if (!arguments.length) {
198 return this._currentRetry;
199 }
200 this._currentRetry = n;
201};
202
203/**
204 * Return the full title generated by recursively concatenating the parent's
205 * full title.
206 *
207 * @memberof Mocha.Runnable
208 * @public
209 * @return {string}
210 */
211Runnable.prototype.fullTitle = function() {
212 return this.titlePath().join(' ');
213};
214
215/**
216 * Return the title path generated by concatenating the parent's title path with the title.
217 *
218 * @memberof Mocha.Runnable
219 * @public
220 * @return {string}
221 */
222Runnable.prototype.titlePath = function() {
223 return this.parent.titlePath().concat([this.title]);
224};
225
226/**
227 * Clear the timeout.
228 *
229 * @private
230 */
231Runnable.prototype.clearTimeout = function() {
232 clearTimeout(this.timer);
233};
234
235/**
236 * Reset the timeout.
237 *
238 * @private
239 */
240Runnable.prototype.resetTimeout = function() {
241 var self = this;
242 var ms = this.timeout() || 1e9;
243
244 if (!this._enableTimeouts) {
245 return;
246 }
247 this.clearTimeout();
248 this.timer = setTimeout(function() {
249 if (!self._enableTimeouts) {
250 return;
251 }
252 self.callback(self._timeoutError(ms));
253 self.timedOut = true;
254 }, ms);
255};
256
257/**
258 * Set or get a list of whitelisted globals for this test run.
259 *
260 * @private
261 * @param {string[]} globals
262 */
263Runnable.prototype.globals = function(globals) {
264 if (!arguments.length) {
265 return this._allowedGlobals;
266 }
267 this._allowedGlobals = globals;
268};
269
270/**
271 * Run the test and invoke `fn(err)`.
272 *
273 * @param {Function} fn
274 * @private
275 */
276Runnable.prototype.run = function(fn) {
277 var self = this;
278 var start = new Date();
279 var ctx = this.ctx;
280 var finished;
281 var errorWasHandled = false;
282
283 // Sometimes the ctx exists, but it is not runnable
284 if (ctx && ctx.runnable) {
285 ctx.runnable(this);
286 }
287
288 // called multiple times
289 function multiple(err) {
290 if (errorWasHandled) {
291 return;
292 }
293 errorWasHandled = true;
294 self.emit('error', createMultipleDoneError(self, err));
295 }
296
297 // finished
298 function done(err) {
299 var ms = self.timeout();
300 if (self.timedOut) {
301 return;
302 }
303
304 if (finished) {
305 return multiple(err);
306 }
307
308 self.clearTimeout();
309 self.duration = new Date() - start;
310 finished = true;
311 if (!err && self.duration > ms && self._enableTimeouts) {
312 err = self._timeoutError(ms);
313 }
314 fn(err);
315 }
316
317 // for .resetTimeout() and Runner#uncaught()
318 this.callback = done;
319
320 if (this.fn && typeof this.fn.call !== 'function') {
321 done(
322 new TypeError(
323 'A runnable must be passed a function as its second argument.'
324 )
325 );
326 return;
327 }
328
329 // explicit async with `done` argument
330 if (this.async) {
331 this.resetTimeout();
332
333 // allows skip() to be used in an explicit async context
334 this.skip = function asyncSkip() {
335 this.pending = true;
336 done();
337 // halt execution, the uncaught handler will ignore the failure.
338 throw new Pending('async skip; aborting execution');
339 };
340
341 try {
342 callFnAsync(this.fn);
343 } catch (err) {
344 // handles async runnables which actually run synchronously
345 errorWasHandled = true;
346 if (err instanceof Pending) {
347 return; // done() is already called in this.skip()
348 } else if (this.allowUncaught) {
349 throw err;
350 }
351 done(Runnable.toValueOrError(err));
352 }
353 return;
354 }
355
356 // sync or promise-returning
357 try {
358 if (this.isPending()) {
359 done();
360 } else {
361 callFn(this.fn);
362 }
363 } catch (err) {
364 errorWasHandled = true;
365 if (err instanceof Pending) {
366 return done();
367 } else if (this.allowUncaught) {
368 throw err;
369 }
370 done(Runnable.toValueOrError(err));
371 }
372
373 function callFn(fn) {
374 var result = fn.call(ctx);
375 if (result && typeof result.then === 'function') {
376 self.resetTimeout();
377 result.then(
378 function() {
379 done();
380 // Return null so libraries like bluebird do not warn about
381 // subsequently constructed Promises.
382 return null;
383 },
384 function(reason) {
385 done(reason || new Error('Promise rejected with no or falsy reason'));
386 }
387 );
388 } else {
389 if (self.asyncOnly) {
390 return done(
391 new Error(
392 '--async-only option in use without declaring `done()` or returning a promise'
393 )
394 );
395 }
396
397 done();
398 }
399 }
400
401 function callFnAsync(fn) {
402 var result = fn.call(ctx, function(err) {
403 if (err instanceof Error || toString.call(err) === '[object Error]') {
404 return done(err);
405 }
406 if (err) {
407 if (Object.prototype.toString.call(err) === '[object Object]') {
408 return done(
409 new Error('done() invoked with non-Error: ' + JSON.stringify(err))
410 );
411 }
412 return done(new Error('done() invoked with non-Error: ' + err));
413 }
414 if (result && utils.isPromise(result)) {
415 return done(
416 new Error(
417 'Resolution method is overspecified. Specify a callback *or* return a Promise; not both.'
418 )
419 );
420 }
421
422 done();
423 });
424 }
425};
426
427/**
428 * Instantiates a "timeout" error
429 *
430 * @param {number} ms - Timeout (in milliseconds)
431 * @returns {Error} a "timeout" error
432 * @private
433 */
434Runnable.prototype._timeoutError = function(ms) {
435 var msg =
436 'Timeout of ' +
437 ms +
438 'ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves.';
439 if (this.file) {
440 msg += ' (' + this.file + ')';
441 }
442 return new Error(msg);
443};
444
445var constants = utils.defineConstants(
446 /**
447 * {@link Runnable}-related constants.
448 * @public
449 * @memberof Runnable
450 * @readonly
451 * @static
452 * @alias constants
453 * @enum {string}
454 */
455 {
456 /**
457 * Value of `state` prop when a `Runnable` has failed
458 */
459 STATE_FAILED: 'failed',
460 /**
461 * Value of `state` prop when a `Runnable` has passed
462 */
463 STATE_PASSED: 'passed'
464 }
465);
466
467/**
468 * Given `value`, return identity if truthy, otherwise create an "invalid exception" error and return that.
469 * @param {*} [value] - Value to return, if present
470 * @returns {*|Error} `value`, otherwise an `Error`
471 * @private
472 */
473Runnable.toValueOrError = function(value) {
474 return (
475 value ||
476 createInvalidExceptionError(
477 'Runnable failed with falsy or undefined exception. Please throw an Error instead.',
478 value
479 )
480 );
481};
482
483Runnable.constants = constants;