packages/jest-circus/src/utils.ts (393 lines of code) (raw):
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import * as path from 'path';
import co from 'co';
import dedent = require('dedent');
import isGeneratorFn from 'is-generator-fn';
import slash = require('slash');
import StackUtils = require('stack-utils');
import type {AssertionResult, Status} from '@jest/test-result';
import type {Circus, Global} from '@jest/types';
import {ErrorWithStack, convertDescriptorToString, formatTime} from 'jest-util';
import {format as prettyFormat} from 'pretty-format';
import {ROOT_DESCRIBE_BLOCK_NAME, getState} from './state';
const stackUtils = new StackUtils({cwd: 'A path that does not exist'});
const jestEachBuildDir = slash(path.dirname(require.resolve('jest-each')));
function takesDoneCallback(fn: Circus.AsyncFn): fn is Global.DoneTakingTestFn {
return fn.length > 0;
}
function isGeneratorFunction(
fn: Global.PromiseReturningTestFn | Global.GeneratorReturningTestFn,
): fn is Global.GeneratorReturningTestFn {
return isGeneratorFn(fn);
}
export const makeDescribe = (
name: Circus.BlockName,
parent?: Circus.DescribeBlock,
mode?: Circus.BlockMode,
): Circus.DescribeBlock => {
let _mode = mode;
if (parent && !mode) {
// If not set explicitly, inherit from the parent describe.
_mode = parent.mode;
}
return {
type: 'describeBlock', // eslint-disable-next-line sort-keys
children: [],
hooks: [],
mode: _mode,
name: convertDescriptorToString(name),
parent,
tests: [],
};
};
export const makeTest = (
fn: Circus.TestFn,
mode: Circus.TestMode,
name: Circus.TestName,
parent: Circus.DescribeBlock,
timeout: number | undefined,
asyncError: Circus.Exception,
): Circus.TestEntry => ({
type: 'test', // eslint-disable-next-line sort-keys
asyncError,
duration: null,
errors: [],
fn,
invocations: 0,
mode,
name: convertDescriptorToString(name),
parent,
seenDone: false,
startedAt: null,
status: null,
timeout,
});
// Traverse the tree of describe blocks and return true if at least one describe
// block has an enabled test.
const hasEnabledTest = (describeBlock: Circus.DescribeBlock): boolean => {
const {hasFocusedTests, testNamePattern} = getState();
return describeBlock.children.some(child =>
child.type === 'describeBlock'
? hasEnabledTest(child)
: !(
child.mode === 'skip' ||
(hasFocusedTests && child.mode !== 'only') ||
(testNamePattern && !testNamePattern.test(getTestID(child)))
),
);
};
type DescribeHooks = {
beforeAll: Array<Circus.Hook>;
afterAll: Array<Circus.Hook>;
};
export const getAllHooksForDescribe = (
describe: Circus.DescribeBlock,
): DescribeHooks => {
const result: DescribeHooks = {
afterAll: [],
beforeAll: [],
};
if (hasEnabledTest(describe)) {
for (const hook of describe.hooks) {
switch (hook.type) {
case 'beforeAll':
result.beforeAll.push(hook);
break;
case 'afterAll':
result.afterAll.push(hook);
break;
}
}
}
return result;
};
type TestHooks = {
beforeEach: Array<Circus.Hook>;
afterEach: Array<Circus.Hook>;
};
export const getEachHooksForTest = (test: Circus.TestEntry): TestHooks => {
const result: TestHooks = {afterEach: [], beforeEach: []};
let block: Circus.DescribeBlock | undefined | null = test.parent;
do {
const beforeEachForCurrentBlock = [];
for (const hook of block.hooks) {
switch (hook.type) {
case 'beforeEach':
beforeEachForCurrentBlock.push(hook);
break;
case 'afterEach':
result.afterEach.push(hook);
break;
}
}
// 'beforeEach' hooks are executed from top to bottom, the opposite of the
// way we traversed it.
result.beforeEach = [...beforeEachForCurrentBlock, ...result.beforeEach];
} while ((block = block.parent));
return result;
};
export const describeBlockHasTests = (
describe: Circus.DescribeBlock,
): boolean =>
describe.children.some(
child => child.type === 'test' || describeBlockHasTests(child),
);
const _makeTimeoutMessage = (timeout: number, isHook: boolean) =>
`Exceeded timeout of ${formatTime(timeout)} for a ${
isHook ? 'hook' : 'test'
}.\nUse jest.setTimeout(newTimeout) to increase the timeout value, if this is a long-running test.`;
// Global values can be overwritten by mocks or tests. We'll capture
// the original values in the variables before we require any files.
const {setTimeout, clearTimeout} = globalThis;
function checkIsError(error: unknown): error is Error {
return !!(error && (error as Error).message && (error as Error).stack);
}
export const callAsyncCircusFn = (
testOrHook: Circus.TestEntry | Circus.Hook,
testContext: Circus.TestContext,
{isHook, timeout}: {isHook: boolean; timeout: number},
): Promise<unknown> => {
let timeoutID: NodeJS.Timeout;
let completed = false;
const {fn, asyncError} = testOrHook;
return new Promise<void>((resolve, reject) => {
timeoutID = setTimeout(
() => reject(_makeTimeoutMessage(timeout, isHook)),
timeout,
);
// If this fn accepts `done` callback we return a promise that fulfills as
// soon as `done` called.
if (takesDoneCallback(fn)) {
let returnedValue: unknown = undefined;
const done = (reason?: Error | string): void => {
// We need to keep a stack here before the promise tick
const errorAtDone = new ErrorWithStack(undefined, done);
if (!completed && testOrHook.seenDone) {
errorAtDone.message =
'Expected done to be called once, but it was called multiple times.';
if (reason) {
errorAtDone.message += ` Reason: ${prettyFormat(reason, {
maxDepth: 3,
})}`;
}
reject(errorAtDone);
throw errorAtDone;
} else {
testOrHook.seenDone = true;
}
// Use `Promise.resolve` to allow the event loop to go a single tick in case `done` is called synchronously
Promise.resolve().then(() => {
if (returnedValue !== undefined) {
asyncError.message = dedent`
Test functions cannot both take a 'done' callback and return something. Either use a 'done' callback, or return a promise.
Returned value: ${prettyFormat(returnedValue, {maxDepth: 3})}
`;
return reject(asyncError);
}
let errorAsErrorObject: Error;
if (checkIsError(reason)) {
errorAsErrorObject = reason;
} else {
errorAsErrorObject = errorAtDone;
errorAtDone.message = `Failed: ${prettyFormat(reason, {
maxDepth: 3,
})}`;
}
// Consider always throwing, regardless if `reason` is set or not
if (completed && reason) {
errorAsErrorObject.message = `Caught error after test environment was torn down\n\n${errorAsErrorObject.message}`;
throw errorAsErrorObject;
}
return reason ? reject(errorAsErrorObject) : resolve();
});
};
returnedValue = fn.call(testContext, done);
return;
}
let returnedValue: Global.TestReturnValue;
if (isGeneratorFunction(fn)) {
returnedValue = co.wrap(fn).call({});
} else {
try {
returnedValue = fn.call(testContext);
} catch (error) {
reject(error);
return;
}
}
// If it's a Promise, return it. Test for an object with a `then` function
// to support custom Promise implementations.
if (
typeof returnedValue === 'object' &&
returnedValue !== null &&
typeof returnedValue.then === 'function'
) {
returnedValue.then(() => resolve(), reject);
return;
}
if (!isHook && returnedValue !== undefined) {
reject(
new Error(
dedent`
test functions can only return Promise or undefined.
Returned value: ${prettyFormat(returnedValue, {maxDepth: 3})}
`,
),
);
return;
}
// Otherwise this test is synchronous, and if it didn't throw it means
// it passed.
resolve();
})
.then(() => {
completed = true;
// If timeout is not cleared/unrefed the node process won't exit until
// it's resolved.
timeoutID.unref?.();
clearTimeout(timeoutID);
})
.catch(error => {
completed = true;
timeoutID.unref?.();
clearTimeout(timeoutID);
throw error;
});
};
export const getTestDuration = (test: Circus.TestEntry): number | null => {
const {startedAt} = test;
return typeof startedAt === 'number' ? Date.now() - startedAt : null;
};
export const makeRunResult = (
describeBlock: Circus.DescribeBlock,
unhandledErrors: Array<Error>,
): Circus.RunResult => ({
testResults: makeTestResults(describeBlock),
unhandledErrors: unhandledErrors.map(_getError).map(getErrorStack),
});
export const makeSingleTestResult = (
test: Circus.TestEntry,
): Circus.TestResult => {
const {includeTestLocationInResult} = getState();
const testPath = [];
let parent: Circus.TestEntry | Circus.DescribeBlock | undefined = test;
const {status} = test;
invariant(status, 'Status should be present after tests are run.');
do {
testPath.unshift(parent.name);
} while ((parent = parent.parent));
let location = null;
if (includeTestLocationInResult) {
const stackLines = test.asyncError.stack.split('\n');
const stackLine = stackLines[1];
let parsedLine = stackUtils.parseLine(stackLine);
if (parsedLine?.file?.startsWith(jestEachBuildDir)) {
const stackLine = stackLines[4];
parsedLine = stackUtils.parseLine(stackLine);
}
if (
parsedLine &&
typeof parsedLine.column === 'number' &&
typeof parsedLine.line === 'number'
) {
location = {
column: parsedLine.column,
line: parsedLine.line,
};
}
}
const errorsDetailed = test.errors.map(_getError);
return {
duration: test.duration,
errors: errorsDetailed.map(getErrorStack),
errorsDetailed,
invocations: test.invocations,
location,
status,
testPath: Array.from(testPath),
};
};
const makeTestResults = (
describeBlock: Circus.DescribeBlock,
): Circus.TestResults => {
const testResults: Circus.TestResults = [];
for (const child of describeBlock.children) {
switch (child.type) {
case 'describeBlock': {
testResults.push(...makeTestResults(child));
break;
}
case 'test': {
testResults.push(makeSingleTestResult(child));
break;
}
}
}
return testResults;
};
// Return a string that identifies the test (concat of parent describe block
// names + test title)
export const getTestID = (test: Circus.TestEntry): string => {
const titles = [];
let parent: Circus.TestEntry | Circus.DescribeBlock | undefined = test;
do {
titles.unshift(parent.name);
} while ((parent = parent.parent));
titles.shift(); // remove TOP_DESCRIBE_BLOCK_NAME
return titles.join(' ');
};
const _getError = (
errors?: Circus.Exception | [Circus.Exception | undefined, Circus.Exception],
): Error => {
let error;
let asyncError;
if (Array.isArray(errors)) {
error = errors[0];
asyncError = errors[1];
} else {
error = errors;
asyncError = new Error();
}
if (error && (typeof error.stack === 'string' || error.message)) {
return error;
}
asyncError.message = `thrown: ${prettyFormat(error, {maxDepth: 3})}`;
return asyncError;
};
const getErrorStack = (error: Error): string =>
typeof error.stack === 'string' ? error.stack : error.message;
export const addErrorToEachTestUnderDescribe = (
describeBlock: Circus.DescribeBlock,
error: Circus.Exception,
asyncError: Circus.Exception,
): void => {
for (const child of describeBlock.children) {
switch (child.type) {
case 'describeBlock':
addErrorToEachTestUnderDescribe(child, error, asyncError);
break;
case 'test':
child.errors.push([error, asyncError]);
break;
}
}
};
export function invariant(
condition: unknown,
message?: string,
): asserts condition {
if (!condition) {
throw new Error(message);
}
}
export const parseSingleTestResult = (
testResult: Circus.TestResult,
): AssertionResult => {
let status: Status;
if (testResult.status === 'skip') {
status = 'pending';
} else if (testResult.status === 'todo') {
status = 'todo';
} else if (testResult.errors.length > 0) {
status = 'failed';
} else {
status = 'passed';
}
const ancestorTitles = testResult.testPath.filter(
name => name !== ROOT_DESCRIBE_BLOCK_NAME,
);
const title = ancestorTitles.pop();
return {
ancestorTitles,
duration: testResult.duration,
failureDetails: testResult.errorsDetailed,
failureMessages: Array.from(testResult.errors),
fullName: title
? ancestorTitles.concat(title).join(' ')
: ancestorTitles.join(' '),
invocations: testResult.invocations,
location: testResult.location,
numPassingAsserts: 0,
status,
title: testResult.testPath[testResult.testPath.length - 1],
};
};