1 | 'use strict';
|
2 | const concordance = require('concordance');
|
3 | const observableToPromise = require('observable-to-promise');
|
4 | const isPromise = require('is-promise');
|
5 | const isObservable = require('is-observable');
|
6 | const plur = require('plur');
|
7 | const assert = require('./assert');
|
8 | const nowAndTimers = require('./now-and-timers');
|
9 | const concordanceOptions = require('./concordance-options').default;
|
10 |
|
11 | function formatErrorValue(label, error) {
|
12 | const formatted = concordance.format(error, concordanceOptions);
|
13 | return {label, formatted};
|
14 | }
|
15 |
|
16 | const captureSavedError = () => {
|
17 | const limitBefore = Error.stackTraceLimit;
|
18 | Error.stackTraceLimit = 1;
|
19 | const err = new Error();
|
20 | Error.stackTraceLimit = limitBefore;
|
21 | return err;
|
22 | };
|
23 |
|
24 | const testMap = new WeakMap();
|
25 | class 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 |
|
99 | class 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(() => {
|
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 |
|
233 |
|
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 |
|
331 |
|
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 |
|
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()));
|
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 |
|
495 | module.exports = Test;
|