UNPKG

12.1 kBJavaScriptView Raw
1'use strict';
2const concordance = require('concordance');
3const observableToPromise = require('observable-to-promise');
4const isPromise = require('is-promise');
5const isObservable = require('is-observable');
6const plur = require('plur');
7const assert = require('./assert');
8const nowAndTimers = require('./now-and-timers');
9const concordanceOptions = require('./concordance-options').default;
10
11function formatErrorValue(label, error) {
12 const formatted = concordance.format(error, concordanceOptions);
13 return {label, formatted};
14}
15
16const captureSavedError = () => {
17 const limitBefore = Error.stackTraceLimit;
18 Error.stackTraceLimit = 1;
19 const err = new Error();
20 Error.stackTraceLimit = limitBefore;
21 return err;
22};
23
24const testMap = new WeakMap();
25class ExecutionContext extends assert.Assertions {
26 constructor(test) {
27 super({
28 pass: () => {
29 test.countPassedAssertion();
30 },
31 pending: promise => {
32 test.addPendingAssertion(promise);
33 },
34 fail: err => {
35 test.addFailedAssertion(err);
36 },
37 skip: () => {
38 test.countPassedAssertion();
39 },
40 compareWithSnapshot: options => {
41 return test.compareWithSnapshot(options);
42 }
43 });
44 testMap.set(this, test);
45
46 this.snapshot.skip = () => {
47 test.skipSnapshot();
48 };
49
50 this.log = (...inputArgs) => {
51 const args = inputArgs.map(value => {
52 return typeof value === 'string' ?
53 value :
54 concordance.format(value, concordanceOptions);
55 });
56 if (args.length > 0) {
57 test.addLog(args.join(' '));
58 }
59 };
60
61 this.plan = count => {
62 test.plan(count, captureSavedError());
63 };
64
65 this.plan.skip = () => {};
66
67 this.timeout = ms => {
68 test.timeout(ms);
69 };
70 }
71
72 get end() {
73 const end = testMap.get(this).bindEndCallback();
74 const endFn = error => end(error, captureSavedError());
75 return endFn;
76 }
77
78 get title() {
79 return testMap.get(this).title;
80 }
81
82 get context() {
83 return testMap.get(this).contextRef.get();
84 }
85
86 set context(context) {
87 testMap.get(this).contextRef.set(context);
88 }
89
90 _throwsArgStart(assertion, file, line) {
91 testMap.get(this).trackThrows({assertion, file, line});
92 }
93
94 _throwsArgEnd() {
95 testMap.get(this).trackThrows(null);
96 }
97}
98
99class Test {
100 constructor(options) {
101 this.contextRef = options.contextRef;
102 this.failWithoutAssertions = options.failWithoutAssertions;
103 this.fn = options.fn;
104 this.metadata = options.metadata;
105 this.title = options.title;
106 this.logs = [];
107
108 this.snapshotInvocationCount = 0;
109 this.compareWithSnapshot = assertionOptions => {
110 const belongsTo = assertionOptions.id || this.title;
111 const {expected} = assertionOptions;
112 const index = assertionOptions.id ? 0 : this.snapshotInvocationCount++;
113 const label = assertionOptions.id ? '' : assertionOptions.message || `Snapshot ${this.snapshotInvocationCount}`;
114 return options.compareTestSnapshot({belongsTo, expected, index, label});
115 };
116
117 this.skipSnapshot = () => {
118 if (options.updateSnapshots) {
119 this.addFailedAssertion(new Error('Snapshot assertions cannot be skipped when updating snapshots'));
120 } else {
121 this.snapshotInvocationCount++;
122 this.countPassedAssertion();
123 }
124 };
125
126 this.assertCount = 0;
127 this.assertError = undefined;
128 this.calledEnd = false;
129 this.duration = null;
130 this.endCallbackFinisher = null;
131 this.finishDueToAttributedError = null;
132 this.finishDueToInactivity = null;
133 this.finishDueToTimeout = null;
134 this.finishing = false;
135 this.pendingAssertionCount = 0;
136 this.pendingThrowsAssertion = null;
137 this.planCount = null;
138 this.startedAt = 0;
139 this.timeoutTimer = null;
140 this.timeoutMs = 0;
141 }
142
143 bindEndCallback() {
144 if (this.metadata.callback) {
145 return (error, savedError) => {
146 this.endCallback(error, savedError);
147 };
148 }
149
150 throw new Error('`t.end()`` is not supported in this context. To use `t.end()` as a callback, you must use "callback mode" via `test.cb(testName, fn)`');
151 }
152
153 endCallback(error, savedError) {
154 if (this.calledEnd) {
155 this.saveFirstError(new Error('`t.end()` called more than once'));
156 return;
157 }
158
159 this.calledEnd = true;
160
161 if (error) {
162 this.saveFirstError(new assert.AssertionError({
163 actual: error,
164 message: 'Callback called with an error',
165 savedError,
166 values: [formatErrorValue('Callback called with an error:', error)]
167 }));
168 }
169
170 if (this.endCallbackFinisher) {
171 this.endCallbackFinisher();
172 }
173 }
174
175 createExecutionContext() {
176 return new ExecutionContext(this);
177 }
178
179 countPassedAssertion() {
180 if (this.finishing) {
181 this.saveFirstError(new Error('Assertion passed, but test has already finished'));
182 }
183
184 this.assertCount++;
185 this.refreshTimeout();
186 }
187
188 addLog(text) {
189 this.logs.push(text);
190 }
191
192 addPendingAssertion(promise) {
193 if (this.finishing) {
194 this.saveFirstError(new Error('Assertion passed, but test has already finished'));
195 }
196
197 this.assertCount++;
198 this.pendingAssertionCount++;
199 this.refreshTimeout();
200
201 promise
202 .catch(error => this.saveFirstError(error))
203 .then(() => { // eslint-disable-line promise/prefer-await-to-then
204 this.pendingAssertionCount--;
205 this.refreshTimeout();
206 });
207 }
208
209 addFailedAssertion(error) {
210 if (this.finishing) {
211 this.saveFirstError(new Error('Assertion failed, but test has already finished'));
212 }
213
214 this.assertCount++;
215 this.refreshTimeout();
216 this.saveFirstError(error);
217 }
218
219 saveFirstError(error) {
220 if (!this.assertError) {
221 this.assertError = error;
222 }
223 }
224
225 plan(count, planError) {
226 if (typeof count !== 'number') {
227 throw new TypeError('Expected a number');
228 }
229
230 this.planCount = count;
231
232 // In case the `planCount` doesn't match `assertCount, we need the stack of
233 // this function to throw with a useful stack.
234 this.planError = planError;
235 }
236
237 timeout(ms) {
238 if (this.finishing) {
239 return;
240 }
241
242 this.clearTimeout();
243 this.timeoutMs = ms;
244 this.timeoutTimer = nowAndTimers.setTimeout(() => {
245 this.saveFirstError(new Error('Test timeout exceeded'));
246
247 if (this.finishDueToTimeout) {
248 this.finishDueToTimeout();
249 }
250 }, ms);
251 }
252
253 refreshTimeout() {
254 if (!this.timeoutTimer) {
255 return;
256 }
257
258 if (this.timeoutTimer.refresh) {
259 this.timeoutTimer.refresh();
260 } else {
261 this.timeout(this.timeoutMs);
262 }
263 }
264
265 clearTimeout() {
266 nowAndTimers.clearTimeout(this.timeoutTimer);
267 this.timeoutTimer = null;
268 }
269
270 verifyPlan() {
271 if (!this.assertError && this.planCount !== null && this.planCount !== this.assertCount) {
272 this.saveFirstError(new assert.AssertionError({
273 assertion: 'plan',
274 message: `Planned for ${this.planCount} ${plur('assertion', this.planCount)}, but got ${this.assertCount}.`,
275 operator: '===',
276 savedError: this.planError
277 }));
278 }
279 }
280
281 verifyAssertions() {
282 if (!this.assertError) {
283 if (this.failWithoutAssertions && !this.calledEnd && this.planCount === null && this.assertCount === 0) {
284 this.saveFirstError(new Error('Test finished without running any assertions'));
285 } else if (this.pendingAssertionCount > 0) {
286 this.saveFirstError(new Error('Test finished, but an assertion is still pending'));
287 }
288 }
289 }
290
291 trackThrows(pending) {
292 this.pendingThrowsAssertion = pending;
293 }
294
295 detectImproperThrows(error) {
296 if (!this.pendingThrowsAssertion) {
297 return false;
298 }
299
300 const pending = this.pendingThrowsAssertion;
301 this.pendingThrowsAssertion = null;
302
303 const values = [];
304 if (error) {
305 values.push(formatErrorValue(`The following error was thrown, possibly before \`t.${pending.assertion}()\` could be called:`, error));
306 }
307
308 this.saveFirstError(new assert.AssertionError({
309 assertion: pending.assertion,
310 fixedSource: {file: pending.file, line: pending.line},
311 improperUsage: true,
312 message: `Improper usage of \`t.${pending.assertion}()\` detected`,
313 savedError: error instanceof Error && error,
314 values
315 }));
316 return true;
317 }
318
319 waitForPendingThrowsAssertion() {
320 return new Promise(resolve => {
321 this.finishDueToAttributedError = () => {
322 resolve(this.finishPromised());
323 };
324
325 this.finishDueToInactivity = () => {
326 this.detectImproperThrows();
327 resolve(this.finishPromised());
328 };
329
330 // Wait up to a second to see if an error can be attributed to the
331 // pending assertion.
332 nowAndTimers.setTimeout(() => this.finishDueToInactivity(), 1000).unref();
333 });
334 }
335
336 attributeLeakedError(error) {
337 if (!this.detectImproperThrows(error)) {
338 return false;
339 }
340
341 this.finishDueToAttributedError();
342 return true;
343 }
344
345 callFn() {
346 try {
347 return {
348 ok: true,
349 retval: this.fn.call(null, this.createExecutionContext())
350 };
351 } catch (error) {
352 return {
353 ok: false,
354 error
355 };
356 }
357 }
358
359 run() {
360 this.startedAt = nowAndTimers.now();
361
362 const result = this.callFn();
363 if (!result.ok) {
364 if (!this.detectImproperThrows(result.error)) {
365 this.saveFirstError(new assert.AssertionError({
366 message: 'Error thrown in test',
367 savedError: result.error instanceof Error && result.error,
368 values: [formatErrorValue('Error thrown in test:', result.error)]
369 }));
370 }
371
372 return this.finishPromised();
373 }
374
375 const returnedObservable = isObservable(result.retval);
376 const returnedPromise = isPromise(result.retval);
377
378 let promise;
379 if (returnedObservable) {
380 promise = observableToPromise(result.retval);
381 } else if (returnedPromise) {
382 // `retval` can be any thenable, so convert to a proper promise.
383 promise = Promise.resolve(result.retval);
384 }
385
386 if (this.metadata.callback) {
387 if (returnedObservable || returnedPromise) {
388 const asyncType = returnedObservable ? 'observables' : 'promises';
389 this.saveFirstError(new Error(`Do not return ${asyncType} from tests declared via \`test.cb(...)\`, if you want to return a promise simply declare the test via \`test(...)\``));
390 return this.finishPromised();
391 }
392
393 if (this.calledEnd) {
394 return this.finishPromised();
395 }
396
397 return new Promise(resolve => {
398 this.endCallbackFinisher = () => {
399 resolve(this.finishPromised());
400 };
401
402 this.finishDueToAttributedError = () => {
403 resolve(this.finishPromised());
404 };
405
406 this.finishDueToTimeout = () => {
407 resolve(this.finishPromised());
408 };
409
410 this.finishDueToInactivity = () => {
411 this.saveFirstError(new Error('`t.end()` was never called'));
412 resolve(this.finishPromised());
413 };
414 });
415 }
416
417 if (promise) {
418 return new Promise(resolve => {
419 this.finishDueToAttributedError = () => {
420 resolve(this.finishPromised());
421 };
422
423 this.finishDueToTimeout = () => {
424 resolve(this.finishPromised());
425 };
426
427 this.finishDueToInactivity = () => {
428 const error = returnedObservable ?
429 new Error('Observable returned by test never completed') :
430 new Error('Promise returned by test never resolved');
431 this.saveFirstError(error);
432 resolve(this.finishPromised());
433 };
434
435 promise
436 .catch(error => {
437 if (!this.detectImproperThrows(error)) {
438 this.saveFirstError(new assert.AssertionError({
439 message: 'Rejected promise returned by test',
440 savedError: error instanceof Error && error,
441 values: [formatErrorValue('Rejected promise returned by test. Reason:', error)]
442 }));
443 }
444 })
445 .then(() => resolve(this.finishPromised())); // eslint-disable-line promise/prefer-await-to-then
446 });
447 }
448
449 return this.finishPromised();
450 }
451
452 finish() {
453 this.finishing = true;
454
455 if (!this.assertError && this.pendingThrowsAssertion) {
456 return this.waitForPendingThrowsAssertion();
457 }
458
459 this.clearTimeout();
460 this.verifyPlan();
461 this.verifyAssertions();
462
463 this.duration = nowAndTimers.now() - this.startedAt;
464
465 let error = this.assertError;
466 let passed = !error;
467
468 if (this.metadata.failing) {
469 passed = !passed;
470
471 if (passed) {
472 error = null;
473 } else {
474 error = new Error('Test was expected to fail, but succeeded, you should stop marking the test as failing');
475 }
476 }
477
478 return {
479 duration: this.duration,
480 error,
481 logs: this.logs,
482 metadata: this.metadata,
483 passed,
484 title: this.title
485 };
486 }
487
488 finishPromised() {
489 return new Promise(resolve => {
490 resolve(this.finish());
491 });
492 }
493}
494
495module.exports = Test;