'use strict'; const isGeneratorFn = require('is-generator-fn'); const co = require('co-with-promise'); const concordance = require('concordance'); const observableToPromise = require('observable-to-promise'); const isPromise = require('is-promise'); const isObservable = require('is-observable'); const plur = require('plur'); const assert = require('./assert'); const globals = require('./globals'); const concordanceOptions = require('./concordance-options').default; function formatErrorValue(label, error) { const formatted = concordance.format(error, concordanceOptions); return {label, formatted}; } class SkipApi { constructor(test) { this._test = test; } } const captureStack = start => { const limitBefore = Error.stackTraceLimit; Error.stackTraceLimit = 1; const obj = {}; Error.captureStackTrace(obj, start); Error.stackTraceLimit = limitBefore; return obj.stack; }; class ExecutionContext { constructor(test) { Object.defineProperties(this, { _test: {value: test}, skip: {value: new SkipApi(test)} }); } plan(ct) { this._test.plan(ct, captureStack(this.plan)); } get end() { const end = this._test.bindEndCallback(); const endFn = err => end(err, captureStack(endFn)); return endFn; } get title() { return this._test.title; } get context() { const contextRef = this._test.contextRef; return contextRef && contextRef.context; } set context(context) { const contextRef = this._test.contextRef; if (!contextRef) { this._test.saveFirstError(new Error(`\`t.context\` is not available in ${this._test.metadata.type} tests`)); return; } contextRef.context = context; } _throwsArgStart(assertion, file, line) { this._test.trackThrows({assertion, file, line}); } _throwsArgEnd() { this._test.trackThrows(null); } } { const assertions = assert.wrapAssertions({ pass(executionContext) { executionContext._test.countPassedAssertion(); }, pending(executionContext, promise) { executionContext._test.addPendingAssertion(promise); }, fail(executionContext, error) { executionContext._test.addFailedAssertion(error); } }); Object.assign(ExecutionContext.prototype, assertions); function skipFn() { this._test.countPassedAssertion(); } Object.keys(assertions).forEach(el => { SkipApi.prototype[el] = skipFn; }); } class Test { constructor(options) { this.contextRef = options.contextRef; this.failWithoutAssertions = options.failWithoutAssertions; this.fn = isGeneratorFn(options.fn) ? co.wrap(options.fn) : options.fn; this.metadata = options.metadata; this.onResult = options.onResult; this.title = options.title; this.snapshotInvocationCount = 0; this.compareWithSnapshot = assertionOptions => { const belongsTo = assertionOptions.id || this.title; const expected = assertionOptions.expected; const index = assertionOptions.id ? 0 : this.snapshotInvocationCount++; const label = assertionOptions.id ? '' : assertionOptions.message || `Snapshot ${this.snapshotInvocationCount}`; return options.compareTestSnapshot({belongsTo, expected, index, label}); }; this.assertCount = 0; this.assertError = undefined; this.calledEnd = false; this.duration = null; this.endCallbackFinisher = null; this.finishDueToAttributedError = null; this.finishDueToInactivity = null; this.finishing = false; this.pendingAssertionCount = 0; this.pendingThrowsAssertion = null; this.planCount = null; this.startedAt = 0; } bindEndCallback() { if (this.metadata.callback) { return (err, stack) => { this.endCallback(err, stack); }; } 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)`'); } endCallback(err, stack) { if (this.calledEnd) { this.saveFirstError(new Error('`t.end()` called more than once')); return; } this.calledEnd = true; if (err) { this.saveFirstError(new assert.AssertionError({ actual: err, message: 'Callback called with an error', stack, values: [formatErrorValue('Callback called with an error:', err)] })); } if (this.endCallbackFinisher) { this.endCallbackFinisher(); } } createExecutionContext() { return new ExecutionContext(this); } countPassedAssertion() { if (this.finishing) { this.saveFirstError(new Error('Assertion passed, but test has already finished')); } this.assertCount++; } addPendingAssertion(promise) { if (this.finishing) { this.saveFirstError(new Error('Assertion passed, but test has already finished')); } this.assertCount++; this.pendingAssertionCount++; promise .catch(err => this.saveFirstError(err)) .then(() => this.pendingAssertionCount--); } addFailedAssertion(error) { if (this.finishing) { this.saveFirstError(new Error('Assertion failed, but test has already finished')); } this.assertCount++; this.saveFirstError(error); } saveFirstError(err) { if (!this.assertError) { this.assertError = err; } } plan(count, planStack) { if (typeof count !== 'number') { throw new TypeError('Expected a number'); } this.planCount = count; // In case the `planCount` doesn't match `assertCount, we need the stack of // this function to throw with a useful stack. this.planStack = planStack; } verifyPlan() { if (!this.assertError && this.planCount !== null && this.planCount !== this.assertCount) { this.saveFirstError(new assert.AssertionError({ assertion: 'plan', message: `Planned for ${this.planCount} ${plur('assertion', this.planCount)}, but got ${this.assertCount}.`, operator: '===', stack: this.planStack })); } } verifyAssertions() { if (!this.assertError) { if (this.failWithoutAssertions && !this.calledEnd && this.planCount === null && this.assertCount === 0) { this.saveFirstError(new Error('Test finished without running any assertions')); } else if (this.pendingAssertionCount > 0) { this.saveFirstError(new Error('Test finished, but an assertion is still pending')); } } } trackThrows(pending) { this.pendingThrowsAssertion = pending; } detectImproperThrows(err) { if (!this.pendingThrowsAssertion) { return false; } const pending = this.pendingThrowsAssertion; this.pendingThrowsAssertion = null; const values = []; if (err) { values.push(formatErrorValue(`The following error was thrown, possibly before \`t.${pending.assertion}()\` could be called:`, err)); } this.saveFirstError(new assert.AssertionError({ assertion: pending.assertion, fixedSource: {file: pending.file, line: pending.line}, improperUsage: true, message: `Improper usage of \`t.${pending.assertion}()\` detected`, stack: err instanceof Error && err.stack, values })); return true; } waitForPendingThrowsAssertion() { return new Promise(resolve => { this.finishDueToAttributedError = () => { resolve(this.finishPromised()); }; this.finishDueToInactivity = () => { this.detectImproperThrows(); resolve(this.finishPromised()); }; // Wait up to a second to see if an error can be attributed to the // pending assertion. globals.setTimeout(() => this.finishDueToInactivity(), 1000).unref(); }); } attributeLeakedError(err) { if (!this.detectImproperThrows(err)) { return false; } this.finishDueToAttributedError(); return true; } callFn() { try { return { ok: true, retval: this.fn(this.createExecutionContext()) }; } catch (err) { return { ok: false, error: err }; } } run() { this.startedAt = globals.now(); const result = this.callFn(); if (!result.ok) { if (!this.detectImproperThrows(result.error)) { this.saveFirstError(new assert.AssertionError({ message: 'Error thrown in test', stack: result.error instanceof Error && result.error.stack, values: [formatErrorValue('Error thrown in test:', result.error)] })); } return this.finish(); } const returnedObservable = isObservable(result.retval); const returnedPromise = isPromise(result.retval); let promise; if (returnedObservable) { promise = observableToPromise(result.retval); } else if (returnedPromise) { // `retval` can be any thenable, so convert to a proper promise. promise = Promise.resolve(result.retval); } if (this.metadata.callback) { if (returnedObservable || returnedPromise) { const asyncType = returnedObservable ? 'observables' : 'promises'; 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(...)\``)); return this.finish(); } if (this.calledEnd) { return this.finish(); } return new Promise(resolve => { this.endCallbackFinisher = () => { resolve(this.finishPromised()); }; this.finishDueToAttributedError = () => { resolve(this.finishPromised()); }; this.finishDueToInactivity = () => { this.saveFirstError(new Error('`t.end()` was never called')); resolve(this.finishPromised()); }; }); } if (promise) { return new Promise(resolve => { this.finishDueToAttributedError = () => { resolve(this.finishPromised()); }; this.finishDueToInactivity = () => { const err = returnedObservable ? new Error('Observable returned by test never completed') : new Error('Promise returned by test never resolved'); this.saveFirstError(err); resolve(this.finishPromised()); }; promise .catch(err => { if (!this.detectImproperThrows(err)) { this.saveFirstError(new assert.AssertionError({ message: 'Rejected promise returned by test', stack: err instanceof Error && err.stack, values: [formatErrorValue('Rejected promise returned by test. Reason:', err)] })); } }) .then(() => resolve(this.finishPromised())); }); } return this.finish(); } finish() { this.finishing = true; if (!this.assertError && this.pendingThrowsAssertion) { return this.waitForPendingThrowsAssertion(); } this.verifyPlan(); this.verifyAssertions(); this.duration = globals.now() - this.startedAt; let reason = this.assertError; let passed = !reason; if (this.metadata.failing) { passed = !passed; if (passed) { reason = undefined; } else { reason = new Error('Test was expected to fail, but succeeded, you should stop marking the test as failing'); } } this.onResult({ passed, result: this, reason }); return passed; } finishPromised() { return new Promise(resolve => { resolve(this.finish()); }); } } module.exports = Test;