packages/jest-runner/src/runTest.ts (310 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 chalk = require('chalk');
import * as fs from 'graceful-fs';
import sourcemapSupport = require('source-map-support');
import {
BufferedConsole,
CustomConsole,
LogMessage,
LogType,
NullConsole,
getConsoleOutput,
} from '@jest/console';
import type {JestEnvironment} from '@jest/environment';
import type {TestFileEvent, TestResult} from '@jest/test-result';
import {createScriptTransformer} from '@jest/transform';
import type {Config} from '@jest/types';
import * as docblock from 'jest-docblock';
import LeakDetector from 'jest-leak-detector';
import {formatExecError} from 'jest-message-util';
import Resolver, {resolveTestEnvironment} from 'jest-resolve';
import type RuntimeClass from 'jest-runtime';
import {ErrorWithStack, interopRequireDefault, setGlobal} from 'jest-util';
import type {TestFramework, TestRunnerContext} from './types';
type RunTestInternalResult = {
leakDetector: LeakDetector | null;
result: TestResult;
};
function freezeConsole(
testConsole: BufferedConsole | CustomConsole | NullConsole,
config: Config.ProjectConfig,
) {
// @ts-expect-error: `_log` is `private` - we should figure out some proper API here
testConsole._log = function fakeConsolePush(
_type: LogType,
message: LogMessage,
) {
const error = new ErrorWithStack(
`${chalk.red(
`${chalk.bold(
'Cannot log after tests are done.',
)} Did you forget to wait for something async in your test?`,
)}\nAttempted to log "${message}".`,
fakeConsolePush,
);
const formattedError = formatExecError(
error,
config,
{noStackTrace: false},
undefined,
true,
);
process.stderr.write(`\n${formattedError}\n`);
process.exitCode = 1;
};
}
// Keeping the core of "runTest" as a separate function (as "runTestInternal")
// is key to be able to detect memory leaks. Since all variables are local to
// the function, when "runTestInternal" finishes its execution, they can all be
// freed, UNLESS something else is leaking them (and that's why we can detect
// the leak!).
//
// If we had all the code in a single function, we should manually nullify all
// references to verify if there is a leak, which is not maintainable and error
// prone. That's why "runTestInternal" CANNOT be inlined inside "runTest".
async function runTestInternal(
path: string,
globalConfig: Config.GlobalConfig,
projectConfig: Config.ProjectConfig,
resolver: Resolver,
context?: TestRunnerContext,
sendMessageToJest?: TestFileEvent,
): Promise<RunTestInternalResult> {
const testSource = fs.readFileSync(path, 'utf8');
const docblockPragmas = docblock.parse(docblock.extract(testSource));
const customEnvironment = docblockPragmas['jest-environment'];
let testEnvironment = projectConfig.testEnvironment;
if (customEnvironment) {
if (Array.isArray(customEnvironment)) {
throw new Error(
`You can only define a single test environment through docblocks, got "${customEnvironment.join(
', ',
)}"`,
);
}
testEnvironment = resolveTestEnvironment({
...projectConfig,
requireResolveFunction: require.resolve,
testEnvironment: customEnvironment,
});
}
const cacheFS = new Map([[path, testSource]]);
const transformer = await createScriptTransformer(projectConfig, cacheFS);
const TestEnvironment: typeof JestEnvironment =
await transformer.requireAndTranspileModule(testEnvironment);
const testFramework: TestFramework =
await transformer.requireAndTranspileModule(
process.env.JEST_JASMINE === '1'
? require.resolve('jest-jasmine2')
: projectConfig.testRunner,
);
const Runtime: typeof RuntimeClass = interopRequireDefault(
projectConfig.runtime
? require(projectConfig.runtime)
: require('jest-runtime'),
).default;
const consoleOut = globalConfig.useStderr ? process.stderr : process.stdout;
const consoleFormatter = (type: LogType, message: LogMessage) =>
getConsoleOutput(
// 4 = the console call is buried 4 stack frames deep
BufferedConsole.write([], type, message, 4),
projectConfig,
globalConfig,
);
let testConsole;
if (globalConfig.silent) {
testConsole = new NullConsole(consoleOut, consoleOut, consoleFormatter);
} else if (globalConfig.verbose) {
testConsole = new CustomConsole(consoleOut, consoleOut, consoleFormatter);
} else {
testConsole = new BufferedConsole();
}
let extraTestEnvironmentOptions;
const docblockEnvironmentOptions =
docblockPragmas['jest-environment-options'];
if (typeof docblockEnvironmentOptions === 'string') {
extraTestEnvironmentOptions = JSON.parse(docblockEnvironmentOptions);
}
const environment = new TestEnvironment(
{
globalConfig,
projectConfig: extraTestEnvironmentOptions
? {
...projectConfig,
testEnvironmentOptions: {
...projectConfig.testEnvironmentOptions,
...extraTestEnvironmentOptions,
},
}
: projectConfig,
},
{
console: testConsole,
docblockPragmas,
testPath: path,
},
);
if (typeof environment.getVmContext !== 'function') {
console.error(
`Test environment found at "${testEnvironment}" does not export a "getVmContext" method, which is mandatory from Jest 27. This method is a replacement for "runScript".`,
);
process.exit(1);
}
const leakDetector = projectConfig.detectLeaks
? new LeakDetector(environment)
: null;
setGlobal(environment.global, 'console', testConsole);
const runtime = new Runtime(
projectConfig,
environment,
resolver,
transformer,
cacheFS,
{
changedFiles: context?.changedFiles,
collectCoverage: globalConfig.collectCoverage,
collectCoverageFrom: globalConfig.collectCoverageFrom,
collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom,
coverageProvider: globalConfig.coverageProvider,
sourcesRelatedToTestsInChangedFiles:
context?.sourcesRelatedToTestsInChangedFiles,
},
path,
);
const start = Date.now();
for (const path of projectConfig.setupFiles) {
const esm = runtime.unstable_shouldLoadAsEsm(path);
if (esm) {
await runtime.unstable_importModule(path);
} else {
const setupFile = runtime.requireModule(path);
if (typeof setupFile === 'function') {
await setupFile();
}
}
}
const sourcemapOptions: sourcemapSupport.Options = {
environment: 'node',
handleUncaughtExceptions: false,
retrieveSourceMap: source => {
const sourceMapSource = runtime.getSourceMaps()?.get(source);
if (sourceMapSource) {
try {
return {
map: JSON.parse(fs.readFileSync(sourceMapSource, 'utf8')),
url: source,
};
} catch {}
}
return null;
},
};
// For tests
runtime
.requireInternalModule<typeof import('source-map-support')>(
require.resolve('source-map-support'),
'source-map-support',
)
.install(sourcemapOptions);
// For runtime errors
sourcemapSupport.install(sourcemapOptions);
if (
environment.global &&
environment.global.process &&
environment.global.process.exit
) {
const realExit = environment.global.process.exit;
environment.global.process.exit = function exit(...args: Array<any>) {
const error = new ErrorWithStack(
`process.exit called with "${args.join(', ')}"`,
exit,
);
const formattedError = formatExecError(
error,
projectConfig,
{noStackTrace: false},
undefined,
true,
);
process.stderr.write(formattedError);
return realExit(...args);
};
}
// if we don't have `getVmContext` on the env skip coverage
const collectV8Coverage =
globalConfig.coverageProvider === 'v8' &&
typeof environment.getVmContext === 'function';
try {
await environment.setup();
let result: TestResult;
try {
if (collectV8Coverage) {
await runtime.collectV8Coverage();
}
result = await testFramework(
globalConfig,
projectConfig,
environment,
runtime,
path,
sendMessageToJest,
);
} catch (err: any) {
// Access stack before uninstalling sourcemaps
err.stack;
throw err;
} finally {
if (collectV8Coverage) {
await runtime.stopCollectingV8Coverage();
}
}
freezeConsole(testConsole, projectConfig);
const testCount =
result.numPassingTests +
result.numFailingTests +
result.numPendingTests +
result.numTodoTests;
const end = Date.now();
const testRuntime = end - start;
result.perfStats = {
end,
runtime: testRuntime,
slow: testRuntime / 1000 > projectConfig.slowTestThreshold,
start,
};
result.testFilePath = path;
result.console = testConsole.getBuffer();
result.skipped = testCount === result.numPendingTests;
result.displayName = projectConfig.displayName;
const coverage = runtime.getAllCoverageInfoCopy();
if (coverage) {
const coverageKeys = Object.keys(coverage);
if (coverageKeys.length) {
result.coverage = coverage;
}
}
if (collectV8Coverage) {
const v8Coverage = runtime.getAllV8CoverageInfoCopy();
if (v8Coverage && v8Coverage.length > 0) {
result.v8Coverage = v8Coverage;
}
}
if (globalConfig.logHeapUsage) {
// @ts-expect-error
globalThis.gc?.();
result.memoryUsage = process.memoryUsage().heapUsed;
}
// Delay the resolution to allow log messages to be output.
return new Promise(resolve => {
setImmediate(() => resolve({leakDetector, result}));
});
} finally {
runtime.teardown();
await environment.teardown();
sourcemapSupport.resetRetrieveHandlers();
}
}
export default async function runTest(
path: string,
globalConfig: Config.GlobalConfig,
config: Config.ProjectConfig,
resolver: Resolver,
context?: TestRunnerContext,
sendMessageToJest?: TestFileEvent,
): Promise<TestResult> {
const {leakDetector, result} = await runTestInternal(
path,
globalConfig,
config,
resolver,
context,
sendMessageToJest,
);
if (leakDetector) {
// We wanna allow a tiny but time to pass to allow last-minute cleanup
await new Promise(resolve => setTimeout(resolve, 100));
// Resolve leak detector, outside the "runTestInternal" closure.
result.leaks = await leakDetector.isLeaking();
} else {
result.leaks = false;
}
return result;
}