nx/packages/jest/src/plugins/plugin.ts
Jack Hsu 9bc63177df
feat(testing): disable jest runtime by default when inferring targets (#29917)
This PR updates `@nx/jest/plugin` such that `disableJestRuntime` option
is true by default. Users will need to set it to false to bring in
`jest-config` and `jest-runtime` to compute atomized targets. We're
leaving it as an option if anyone runs into discrepancies between our
calculation and what jest-runtime calculates for test files within a
project.



## Current Behavior
Jest runtime is used by default and is potentially slow if you use many
transforms, presets, etc. in the jest config.

## Expected Behavior
Jest runtime is not used by default, and users have to option of
enabling it.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
2025-02-07 13:32:24 -05:00

719 lines
20 KiB
TypeScript

import {
CreateNodes,
CreateNodesContext,
createNodesFromFiles,
CreateNodesV2,
getPackageManagerCommand,
joinPathFragments,
logger,
normalizePath,
NxJsonConfiguration,
ProjectConfiguration,
readJsonFile,
TargetConfiguration,
writeJsonFile,
} from '@nx/devkit';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import {
clearRequireCache,
loadConfigFile,
} from '@nx/devkit/src/utils/config-utils';
import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { minimatch } from 'minimatch';
import { hashObject } from 'nx/src/devkit-internals';
import { getGlobPatternsFromPackageManagerWorkspaces } from 'nx/src/plugins/package-json';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { combineGlobPatterns } from 'nx/src/utils/globs';
import { dirname, isAbsolute, join, relative, resolve } from 'path';
import { getInstalledJestMajorVersion } from '../utils/version-utils';
import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context';
import { normalize, sep } from 'node:path';
const pmc = getPackageManagerCommand();
export interface JestPluginOptions {
targetName?: string;
ciTargetName?: string;
/**
* The name that should be used to group atomized tasks on CI
*/
ciGroupName?: string;
/**
* Whether to use jest-config and jest-runtime are used to load Jest configuration and context.
* Disabling this is much faster but could be less correct since we are using our own config loader
* and test matcher instead of Jest's.
*/
disableJestRuntime?: boolean;
}
type JestTargets = Awaited<ReturnType<typeof buildJestTargets>>;
function readTargetsCache(cachePath: string): Record<string, JestTargets> {
return existsSync(cachePath) ? readJsonFile(cachePath) : {};
}
function writeTargetsToCache(
cachePath: string,
results: Record<string, JestTargets>
) {
writeJsonFile(cachePath, results);
}
const jestConfigGlob = '**/jest.config.{cjs,mjs,js,cts,mts,ts}';
export const createNodesV2: CreateNodesV2<JestPluginOptions> = [
jestConfigGlob,
async (configFiles, options, context) => {
const optionsHash = hashObject(options);
const cachePath = join(workspaceDataDirectory, `jest-${optionsHash}.hash`);
const targetsCache = readTargetsCache(cachePath);
// Cache jest preset(s) to avoid penalties of module load times. Most of jest configs will use the same preset.
const presetCache: Record<string, unknown> = {};
try {
return await createNodesFromFiles(
(configFile, options, context) =>
createNodesInternal(
configFile,
options,
context,
targetsCache,
presetCache
),
configFiles,
options,
context
);
} finally {
writeTargetsToCache(cachePath, targetsCache);
}
},
];
/**
* @deprecated This is replaced with {@link createNodesV2}. Update your plugin to export its own `createNodesV2` function that wraps this one instead.
* This function will change to the v2 function in Nx 20.
*/
export const createNodes: CreateNodes<JestPluginOptions> = [
jestConfigGlob,
(...args) => {
logger.warn(
'`createNodes` is deprecated. Update your plugin to utilize createNodesV2 instead. In Nx 20, this will change to the createNodesV2 API.'
);
return createNodesInternal(...args, {}, {});
},
];
async function createNodesInternal(
configFilePath: string,
options: JestPluginOptions,
context: CreateNodesContext,
targetsCache: Record<string, JestTargets>,
presetCache: Record<string, unknown>
) {
const projectRoot = dirname(configFilePath);
const packageManagerWorkspacesGlob = combineGlobPatterns(
getGlobPatternsFromPackageManagerWorkspaces(context.workspaceRoot)
);
// Do not create a project if package.json and project.json isn't there.
const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot));
if (
!siblingFiles.includes('package.json') &&
!siblingFiles.includes('project.json')
) {
return {};
} else if (
!siblingFiles.includes('project.json') &&
siblingFiles.includes('package.json')
) {
const path = joinPathFragments(projectRoot, 'package.json');
const isPackageJsonProject = minimatch(path, packageManagerWorkspacesGlob);
if (!isPackageJsonProject) {
return {};
}
}
const jestConfigContent = readFileSync(
resolve(context.workspaceRoot, configFilePath),
'utf-8'
);
if (jestConfigContent.includes('getJestProjectsAsync()')) {
// The `getJestProjectsAsync` function uses the project graph, which leads to a
// circular dependency. We can skip this since it's no intended to be used for
// an Nx project.
return {};
}
options = normalizeOptions(options);
const hash = await calculateHashForCreateNodes(projectRoot, options, context);
targetsCache[hash] ??= await buildJestTargets(
configFilePath,
projectRoot,
options,
context,
presetCache
);
const { targets, metadata } = targetsCache[hash];
return {
projects: {
[projectRoot]: {
root: projectRoot,
targets,
metadata,
},
},
};
}
async function buildJestTargets(
configFilePath: string,
projectRoot: string,
options: JestPluginOptions,
context: CreateNodesContext,
presetCache: Record<string, unknown>
): Promise<Pick<ProjectConfiguration, 'targets' | 'metadata'>> {
const absConfigFilePath = resolve(context.workspaceRoot, configFilePath);
if (require.cache[absConfigFilePath]) clearRequireCache();
const rawConfig = await loadConfigFile(absConfigFilePath);
const targets: Record<string, TargetConfiguration> = {};
const namedInputs = getNamedInputs(projectRoot, context);
const target: TargetConfiguration = (targets[options.targetName] = {
command: 'jest',
options: {
cwd: projectRoot,
// Jest registers ts-node with module CJS https://github.com/SimenB/jest/blob/v29.6.4/packages/jest-config/src/readConfigFileAndSetRootDir.ts#L117-L119
// We want to support of ESM via 'module':'nodenext', we need to override the resolution until Jest supports it.
env: { TS_NODE_COMPILER_OPTIONS: '{"moduleResolution":"node10"}' },
},
metadata: {
technologies: ['jest'],
description: 'Run Jest Tests',
help: {
command: `${pmc.exec} jest --help`,
example: {
options: {
coverage: true,
},
},
},
},
});
// Not normalizing it here since also affects options for convert-to-inferred.
const disableJestRuntime = options.disableJestRuntime !== false;
const cache = (target.cache = true);
const inputs = (target.inputs = getInputs(
namedInputs,
rawConfig.preset,
projectRoot,
context.workspaceRoot,
disableJestRuntime
));
let metadata: ProjectConfiguration['metadata'];
const groupName =
options?.ciGroupName ?? deductGroupNameFromTarget(options?.ciTargetName);
if (disableJestRuntime) {
const outputs = (target.outputs = getOutputs(
projectRoot,
rawConfig.coverageDirectory
? join(context.workspaceRoot, projectRoot, rawConfig.coverageDirectory)
: undefined,
undefined,
context
));
if (options?.ciTargetName) {
const testPaths = await getTestPaths(
projectRoot,
rawConfig,
absConfigFilePath,
context,
presetCache
);
const targetGroup = [];
const dependsOn: string[] = [];
metadata = {
targetGroups: {
[groupName]: targetGroup,
},
};
const specIgnoreRegexes: undefined | RegExp[] =
rawConfig.testPathIgnorePatterns?.map(
(p: string) => new RegExp(replaceRootDirInPath(projectRoot, p))
);
for (const testPath of testPaths) {
const relativePath = normalizePath(
relative(join(context.workspaceRoot, projectRoot), testPath)
);
if (specIgnoreRegexes?.some((regex) => regex.test(relativePath))) {
continue;
}
const targetName = `${options.ciTargetName}--${relativePath}`;
dependsOn.push(targetName);
targets[targetName] = {
command: `jest ${relativePath}`,
cache,
inputs,
outputs,
options: {
cwd: projectRoot,
env: { TS_NODE_COMPILER_OPTIONS: '{"moduleResolution":"node10"}' },
},
metadata: {
technologies: ['jest'],
description: `Run Jest Tests in ${relativePath}`,
help: {
command: `${pmc.exec} jest --help`,
example: {
options: {
coverage: true,
},
},
},
},
};
targetGroup.push(targetName);
}
if (targetGroup.length > 0) {
targets[options.ciTargetName] = {
executor: 'nx:noop',
cache: true,
inputs,
outputs,
dependsOn,
metadata: {
technologies: ['jest'],
description: 'Run Jest Tests in CI',
nonAtomizedTarget: options.targetName,
help: {
command: `${pmc.exec} jest --help`,
example: {
options: {
coverage: true,
},
},
},
},
};
targetGroup.unshift(options.ciTargetName);
}
}
} else {
const { readConfig } = requireJestUtil<typeof import('jest-config')>(
'jest-config',
projectRoot,
context.workspaceRoot
);
let config;
try {
config = await readConfig(
{
_: [],
$0: undefined,
},
rawConfig,
undefined,
dirname(absConfigFilePath)
);
} catch (e) {
console.error(e);
throw e;
}
const outputs = (target.outputs = getOutputs(
projectRoot,
config.globalConfig?.coverageDirectory,
config.globalConfig?.outputFile,
context
));
if (options?.ciTargetName) {
// nx-ignore-next-line
const { default: Runtime } = requireJestUtil<
typeof import('jest-runtime')
>('jest-runtime', projectRoot, context.workspaceRoot);
const jestContext = await Runtime.createContext(config.projectConfig, {
maxWorkers: 1,
watchman: false,
});
const jest = require(resolveJestPath(
projectRoot,
context.workspaceRoot
)) as typeof import('jest');
const source = new jest.SearchSource(jestContext);
const jestVersion = getInstalledJestMajorVersion()!;
const specs =
jestVersion >= 30
? await source.getTestPaths(config.globalConfig, config.projectConfig)
: await source.getTestPaths(config.globalConfig);
const testPaths = new Set(specs.tests.map(({ path }) => path));
if (testPaths.size > 0) {
const targetGroup = [];
metadata = {
targetGroups: {
[groupName]: targetGroup,
},
};
const dependsOn: string[] = [];
targets[options.ciTargetName] = {
executor: 'nx:noop',
cache: true,
inputs,
outputs,
dependsOn,
metadata: {
technologies: ['jest'],
description: 'Run Jest Tests in CI',
nonAtomizedTarget: options.targetName,
help: {
command: `${pmc.exec} jest --help`,
example: {
options: {
coverage: true,
},
},
},
},
};
targetGroup.push(options.ciTargetName);
for (const testPath of testPaths) {
const relativePath = normalizePath(
relative(join(context.workspaceRoot, projectRoot), testPath)
);
const targetName = `${options.ciTargetName}--${relativePath}`;
dependsOn.push(targetName);
targets[targetName] = {
command: `jest ${relativePath}`,
cache,
inputs,
outputs,
options: {
cwd: projectRoot,
env: {
TS_NODE_COMPILER_OPTIONS: '{"moduleResolution":"node10"}',
},
},
metadata: {
technologies: ['jest'],
description: `Run Jest Tests in ${relativePath}`,
help: {
command: `${pmc.exec} jest --help`,
example: {
options: {
coverage: true,
},
},
},
},
};
targetGroup.push(targetName);
}
}
}
}
return { targets, metadata };
}
function getInputs(
namedInputs: NxJsonConfiguration['namedInputs'],
preset: string,
projectRoot: string,
workspaceRoot: string,
disableJestRuntime?: boolean
): TargetConfiguration['inputs'] {
const inputs: TargetConfiguration['inputs'] = [
...('production' in namedInputs
? ['default', '^production']
: ['default', '^default']),
];
const externalDependencies = ['jest'];
const presetInput = disableJestRuntime
? resolvePresetInputWithoutJestResolver(preset, projectRoot, workspaceRoot)
: resolvePresetInputWithJestResolver(preset, projectRoot, workspaceRoot);
if (presetInput) {
if (
typeof presetInput !== 'string' &&
'externalDependencies' in presetInput
) {
externalDependencies.push(...presetInput.externalDependencies);
} else {
inputs.push(presetInput);
}
}
inputs.push({ externalDependencies });
return inputs;
}
function resolvePresetInputWithoutJestResolver(
presetValue: string | undefined,
projectRoot: string,
workspaceRoot: string
): TargetConfiguration['inputs'][number] | null {
if (!presetValue) return null;
const presetPath = replaceRootDirInPath(projectRoot, presetValue);
const isNpmPackage = !presetValue.startsWith('.') && !isAbsolute(presetPath);
if (isNpmPackage) {
return { externalDependencies: [presetValue] };
}
if (presetPath.startsWith('..')) {
const relativePath = relative(workspaceRoot, join(projectRoot, presetPath));
return join('{workspaceRoot}', relativePath);
} else {
const relativePath = relative(projectRoot, presetPath);
return join('{projectRoot}', relativePath);
}
}
// preset resolution adapted from:
// https://github.com/jestjs/jest/blob/c54bccd657fb4cf060898717c09f633b4da3eec4/packages/jest-config/src/normalize.ts#L122
function resolvePresetInputWithJestResolver(
presetValue: string | undefined,
projectRoot: string,
workspaceRoot: string
): TargetConfiguration['inputs'][number] | null {
if (!presetValue) return null;
let presetPath = replaceRootDirInPath(projectRoot, presetValue);
const isNpmPackage = !presetValue.startsWith('.') && !isAbsolute(presetPath);
presetPath = presetPath.startsWith('.')
? presetPath
: join(presetPath, 'jest-preset');
const { default: jestResolve } = requireJestUtil<
typeof import('jest-resolve')
>('jest-resolve', projectRoot, workspaceRoot);
const presetModule = jestResolve.findNodeModule(presetPath, {
basedir: projectRoot,
extensions: ['.json', '.js', '.cjs', '.mjs'],
});
if (!presetModule) {
return null;
}
if (isNpmPackage) {
return { externalDependencies: [presetValue] };
}
const relativePath = relative(join(workspaceRoot, projectRoot), presetModule);
return relativePath.startsWith('..')
? join('{workspaceRoot}', join(projectRoot, relativePath))
: join('{projectRoot}', relativePath);
}
// Adapted from here https://github.com/jestjs/jest/blob/c13bca3/packages/jest-config/src/utils.ts#L57-L69
function replaceRootDirInPath(rootDir: string, filePath: string): string {
if (!filePath.startsWith('<rootDir>')) {
return filePath;
}
return resolve(rootDir, normalize(`./${filePath.slice('<rootDir>'.length)}`));
}
function getOutputs(
projectRoot: string,
coverageDirectory: string | undefined,
outputFile: string | undefined,
context: CreateNodesContext
): string[] {
function getOutput(path: string): string {
const relativePath = relative(
join(context.workspaceRoot, projectRoot),
path
);
if (relativePath.startsWith('..')) {
return join('{workspaceRoot}', join(projectRoot, relativePath));
} else {
return join('{projectRoot}', relativePath);
}
}
const outputs = [];
for (const outputOption of [coverageDirectory, outputFile]) {
if (outputOption) {
outputs.push(getOutput(outputOption));
}
}
return outputs;
}
function normalizeOptions(options: JestPluginOptions): JestPluginOptions {
options ??= {};
options.targetName ??= 'test';
return options;
}
let resolvedJestPaths: Record<string, string>;
function resolveJestPath(projectRoot: string, workspaceRoot: string): string {
resolvedJestPaths ??= {};
if (resolvedJestPaths[projectRoot]) {
return resolvedJestPaths[projectRoot];
}
resolvedJestPaths[projectRoot] = require.resolve('jest', {
paths: [projectRoot, workspaceRoot, __dirname],
});
return resolvedJestPaths[projectRoot];
}
let resolvedJestCorePaths: Record<string, string>;
/**
* Resolves a jest util package version that `jest` is using.
*/
function requireJestUtil<T>(
packageName: string,
projectRoot: string,
workspaceRoot: string
): T {
const jestPath = resolveJestPath(projectRoot, workspaceRoot);
resolvedJestCorePaths ??= {};
if (!resolvedJestCorePaths[jestPath]) {
// nx-ignore-next-line
resolvedJestCorePaths[jestPath] = require.resolve('@jest/core', {
paths: [dirname(jestPath)],
});
}
return require(require.resolve(packageName, {
paths: [dirname(resolvedJestCorePaths[jestPath])],
}));
}
async function getTestPaths(
projectRoot: string,
rawConfig: any,
absConfigFilePath: string,
context: CreateNodesContext,
presetCache: Record<string, unknown>
): Promise<string[]> {
const testMatch = await getJestOption<string[]>(
rawConfig,
absConfigFilePath,
'testMatch',
presetCache
);
let paths = await globWithWorkspaceContext(
context.workspaceRoot,
(
testMatch || [
// Default copied from https://github.com/jestjs/jest/blob/d1a2ed7/packages/jest-config/src/Defaults.ts#L84
'**/__tests__/**/*.?([mc])[jt]s?(x)',
'**/?(*.)+(spec|test).?([mc])[jt]s?(x)',
]
).map((pattern) => join(projectRoot, pattern)),
[]
);
const testRegex = await getJestOption<string[]>(
rawConfig,
absConfigFilePath,
'testRegex',
presetCache
);
if (testRegex) {
const testRegexes = Array.isArray(rawConfig.testRegex)
? rawConfig.testRegex.map((r: string) => new RegExp(r))
: [new RegExp(rawConfig.testRegex)];
paths = paths.filter((path: string) =>
testRegexes.some((r: RegExp) => r.test(path))
);
}
return paths;
}
async function getJestOption<T = any>(
rawConfig: any,
absConfigFilePath: string,
optionName: string,
presetCache: Record<string, unknown>
): Promise<T> {
if (rawConfig[optionName]) return rawConfig[optionName];
if (rawConfig.preset) {
const dir = dirname(absConfigFilePath);
const presetPath = resolve(dir, rawConfig.preset);
try {
let preset = presetCache[presetPath];
if (!preset) {
preset = await loadConfigFile(presetPath);
presetCache[presetPath] = preset;
}
if (preset[optionName]) return preset[optionName];
} catch {
// If preset fails to load, ignore the error and continue.
// This is safe and less jarring for users. They will need to fix the
// preset for Jest to run, and at that point we can read in the correct
// value.
}
}
return undefined;
}
/**
* Helper that tries to deduct the name of the CI group, based on the related target name.
*
* This will work well, when the CI target name follows the documented naming convention or similar (for e.g `test-ci`, `e2e-ci`, `ny-e2e-ci`, etc).
*
* For example, `test-ci` => `TEST (CI)`, `e2e-ci` => `E2E (CI)`, `my-e2e-ci` => `MY E2E (CI)`
*
*
* @param ciTargetName name of the CI target
* @returns the deducted group name or `${ciTargetName.toUpperCase()} (CI)` if cannot be deducted automatically
*/
function deductGroupNameFromTarget(ciTargetName: string | undefined) {
if (!ciTargetName) {
return null;
}
const parts = ciTargetName.split('-').map((v) => v.toUpperCase());
if (parts.length > 1) {
return `${parts.slice(0, -1).join(' ')} (${parts[parts.length - 1]})`;
}
return `${parts[0]} (CI)`; // default group name when there is a single segment
}