diff --git a/docs/generated/packages/jest/documents/overview.md b/docs/generated/packages/jest/documents/overview.md index 190dcf29e113d8..92f5399ef0433d 100644 --- a/docs/generated/packages/jest/documents/overview.md +++ b/docs/generated/packages/jest/documents/overview.md @@ -60,13 +60,16 @@ within the same workspace. In this case, you can configure the `@nx/jest/plugin` "include": ["e2e/**/*"], "options": { "targetName": "e2e-local", - "ciTargetName": "e2e-ci" + "ciTargetName": "e2e-ci", + "disableJestRuntime": false } } ] } ``` +If you experience slowness from `@nx/jest/plugin`, then set `disableJestRuntime` to `true` to skip creating the Jest runtime. By disabling the Jest runtime, Nx will use its own utilities to find `inputs`, `outputs`, and test files for [Atomized targets](/ci/features/split-e2e-tasks). This can reduce computation time by as much as 80%. + ### Splitting E2E Tests If Jest is used to run E2E tests, you can enable [splitting the tasks](/ci/features/split-e2e-tasks) by file to get diff --git a/docs/shared/packages/jest/jest-plugin.md b/docs/shared/packages/jest/jest-plugin.md index 190dcf29e113d8..92f5399ef0433d 100644 --- a/docs/shared/packages/jest/jest-plugin.md +++ b/docs/shared/packages/jest/jest-plugin.md @@ -60,13 +60,16 @@ within the same workspace. In this case, you can configure the `@nx/jest/plugin` "include": ["e2e/**/*"], "options": { "targetName": "e2e-local", - "ciTargetName": "e2e-ci" + "ciTargetName": "e2e-ci", + "disableJestRuntime": false } } ] } ``` +If you experience slowness from `@nx/jest/plugin`, then set `disableJestRuntime` to `true` to skip creating the Jest runtime. By disabling the Jest runtime, Nx will use its own utilities to find `inputs`, `outputs`, and test files for [Atomized targets](/ci/features/split-e2e-tasks). This can reduce computation time by as much as 80%. + ### Splitting E2E Tests If Jest is used to run E2E tests, you can enable [splitting the tasks](/ci/features/split-e2e-tasks) by file to get diff --git a/packages/jest/src/plugins/plugin.spec.ts b/packages/jest/src/plugins/plugin.spec.ts index 254565a0822b45..72a29d69b591c3 100644 --- a/packages/jest/src/plugins/plugin.spec.ts +++ b/packages/jest/src/plugins/plugin.spec.ts @@ -347,6 +347,305 @@ describe('@nx/jest/plugin', () => { expect(results).toMatchSnapshot(); } ); + + describe('disableJestRuntime', () => { + it('should create test and test-ci targets based on jest.config.ts', async () => { + mockJestConfig( + { + coverageDirectory: '../coverage', + testMatch: ['**/*.spec.ts'], + testPathIgnorePatterns: ['ignore.spec.ts'], + }, + context + ); + const results = await createNodesFunction( + ['proj/jest.config.js'], + { + targetName: 'test', + ciTargetName: 'test-ci', + disableJestRuntime: true, + }, + context + ); + + expect(results).toMatchInlineSnapshot(` + [ + [ + "proj/jest.config.js", + { + "projects": { + "proj": { + "metadata": { + "targetGroups": { + "E2E (CI)": [ + "test-ci", + "test-ci--src/unit.spec.ts", + ], + }, + }, + "root": "proj", + "targets": { + "test": { + "cache": true, + "command": "jest", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "jest", + ], + }, + ], + "metadata": { + "description": "Run Jest Tests", + "help": { + "command": "npx jest --help", + "example": { + "options": { + "coverage": true, + }, + }, + }, + "technologies": [ + "jest", + ], + }, + "options": { + "cwd": "proj", + }, + "outputs": [ + "{workspaceRoot}/coverage", + ], + }, + "test-ci": { + "cache": true, + "dependsOn": [ + "test-ci--src/unit.spec.ts", + ], + "executor": "nx:noop", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "jest", + ], + }, + ], + "metadata": { + "description": "Run Jest Tests in CI", + "help": { + "command": "npx jest --help", + "example": { + "options": { + "coverage": true, + }, + }, + }, + "nonAtomizedTarget": "test", + "technologies": [ + "jest", + ], + }, + "outputs": [ + "{workspaceRoot}/coverage", + ], + }, + "test-ci--src/unit.spec.ts": { + "cache": true, + "command": "jest src/unit.spec.ts", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "jest", + ], + }, + ], + "metadata": { + "description": "Run Jest Tests in src/unit.spec.ts", + "help": { + "command": "npx jest --help", + "example": { + "options": { + "coverage": true, + }, + }, + }, + "technologies": [ + "jest", + ], + }, + "options": { + "cwd": "proj", + }, + "outputs": [ + "{workspaceRoot}/coverage", + ], + }, + }, + }, + }, + }, + ], + ] + `); + }); + + it.each` + testRegex + ${'\\.*\\.spec\\.ts'} + ${['\\.*\\.spec\\.ts']} + `( + 'should create test-ci targets from testRegex config option', + async ({ testRegex }) => { + mockJestConfig( + { + coverageDirectory: '../coverage', + testRegex, + testPathIgnorePatterns: ['ignore.spec.ts'], + }, + context + ); + const results = await createNodesFunction( + ['proj/jest.config.js'], + { + targetName: 'test', + ciTargetName: 'test-ci', + disableJestRuntime: true, + }, + context + ); + + expect(results).toMatchInlineSnapshot(` + [ + [ + "proj/jest.config.js", + { + "projects": { + "proj": { + "metadata": { + "targetGroups": { + "E2E (CI)": [ + "test-ci", + "test-ci--src/unit.spec.ts", + ], + }, + }, + "root": "proj", + "targets": { + "test": { + "cache": true, + "command": "jest", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "jest", + ], + }, + ], + "metadata": { + "description": "Run Jest Tests", + "help": { + "command": "npx jest --help", + "example": { + "options": { + "coverage": true, + }, + }, + }, + "technologies": [ + "jest", + ], + }, + "options": { + "cwd": "proj", + }, + "outputs": [ + "{workspaceRoot}/coverage", + ], + }, + "test-ci": { + "cache": true, + "dependsOn": [ + "test-ci--src/unit.spec.ts", + ], + "executor": "nx:noop", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "jest", + ], + }, + ], + "metadata": { + "description": "Run Jest Tests in CI", + "help": { + "command": "npx jest --help", + "example": { + "options": { + "coverage": true, + }, + }, + }, + "nonAtomizedTarget": "test", + "technologies": [ + "jest", + ], + }, + "outputs": [ + "{workspaceRoot}/coverage", + ], + }, + "test-ci--src/unit.spec.ts": { + "cache": true, + "command": "jest src/unit.spec.ts", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "jest", + ], + }, + ], + "metadata": { + "description": "Run Jest Tests in src/unit.spec.ts", + "help": { + "command": "npx jest --help", + "example": { + "options": { + "coverage": true, + }, + }, + }, + "technologies": [ + "jest", + ], + }, + "options": { + "cwd": "proj", + }, + "outputs": [ + "{workspaceRoot}/coverage", + ], + }, + }, + }, + }, + }, + ], + ] + `); + } + ); + }); }); function mockJestConfig(config: any, context: CreateNodesContext) { diff --git a/packages/jest/src/plugins/plugin.ts b/packages/jest/src/plugins/plugin.ts index 4311cd2ae2d950..bcdd4f70567648 100644 --- a/packages/jest/src/plugins/plugin.ts +++ b/packages/jest/src/plugins/plugin.ts @@ -1,4 +1,3 @@ -import type { Config } from '@jest/types'; import { CreateNodes, CreateNodesContext, @@ -28,12 +27,23 @@ 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 { + getFilesInDirectoryUsingContext, + globWithWorkspaceContext, +} from 'nx/src/utils/workspace-context'; +import { normalize } from 'node:path'; const pmc = getPackageManagerCommand(); export interface JestPluginOptions { targetName?: string; ciTargetName?: string; + /** + * Whether to use jest-config and jest-runtime 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>; @@ -88,9 +98,9 @@ export const createNodes: CreateNodes = [ ]; async function createNodesInternal( - configFilePath, - options, - context, + configFilePath: string, + options: JestPluginOptions, + context: CreateNodesContext, targetsCache: Record ) { const projectRoot = dirname(configFilePath); @@ -161,30 +171,11 @@ async function buildJestTargets( ): Promise> { const absConfigFilePath = resolve(context.workspaceRoot, configFilePath); - if (require.cache[absConfigFilePath]) { - clearRequireCache(); - } - + if (require.cache[absConfigFilePath]) clearRequireCache(); const rawConfig = await loadConfigFile(absConfigFilePath); - const { readConfig } = requireJestUtil( - 'jest-config', - projectRoot, - context.workspaceRoot - ); - const config = await readConfig( - { - _: [], - $0: undefined, - }, - rawConfig, - undefined, - dirname(absConfigFilePath) - ); - - const namedInputs = getNamedInputs(projectRoot, context); - const targets: Record = {}; + const namedInputs = getNamedInputs(projectRoot, context); const target: TargetConfiguration = (targets[options.targetName] = { command: 'jest', @@ -208,90 +199,172 @@ async function buildJestTargets( const cache = (target.cache = true); const inputs = (target.inputs = getInputs( namedInputs, - rawConfig, + rawConfig.preset, projectRoot, - context.workspaceRoot + context.workspaceRoot, + options.disableJestRuntime )); - const outputs = (target.outputs = getOutputs(projectRoot, config, context)); let metadata: ProjectConfiguration['metadata']; - if (options?.ciTargetName) { - // nx-ignore-next-line - const { default: Runtime } = requireJestUtil( - 'jest-runtime', - projectRoot, - context.workspaceRoot - ); - const jestContext = await Runtime.createContext(config.projectConfig, { - maxWorkers: 1, - watchman: false, - }); + const groupName = 'E2E (CI)'; - const jest = require(resolveJestPath( + if (options.disableJestRuntime) { + const outputs = (target.outputs = getOutputs( projectRoot, - context.workspaceRoot - )) as typeof import('jest'); - const source = new jest.SearchSource(jestContext); - - const jestVersion = getInstalledJestMajorVersion()!; - const specs = - jestVersion >= 30 - ? // @ts-expect-error Jest 30+ expects the project config as the second argument - 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 groupName = 'E2E (CI)'; - 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, + rawConfig.coverageDirectory + ? join(context.workspaceRoot, projectRoot, rawConfig.coverageDirectory) + : undefined, + undefined, + context + )); + + if (options?.ciTargetName) { + const testPaths = await getTestPaths( + projectRoot, + rawConfig, + absConfigFilePath, + context + ); + + if (testPaths.length > 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, + }; + targetGroup.push(options.ciTargetName); + + 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, + }, + metadata: { + technologies: ['jest'], + description: `Run Jest Tests in ${relativePath}`, + help: { + command: `${pmc.exec} jest --help`, + example: { + options: { + coverage: true, + }, + }, + }, + }, + }; + targetGroup.push(targetName); + } + } + } + } else { + const { readConfig } = requireJestUtil( + 'jest-config', + projectRoot, + context.workspaceRoot + ); + const config = await readConfig( + { + _: [], + $0: undefined, + }, + rawConfig, + undefined, + dirname(absConfigFilePath) + ); + + 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 + ? // @ts-expect-error Jest 30+ expects the project config as the second argument + 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, - options: { - cwd: projectRoot, - }, + dependsOn, metadata: { technologies: ['jest'], - description: `Run Jest Tests in ${relativePath}`, + description: 'Run Jest Tests in CI', + nonAtomizedTarget: options.targetName, help: { command: `${pmc.exec} jest --help`, example: { @@ -302,7 +375,37 @@ async function buildJestTargets( }, }, }; - targetGroup.push(targetName); + 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, + }, + metadata: { + technologies: ['jest'], + description: `Run Jest Tests in ${relativePath}`, + help: { + command: `${pmc.exec} jest --help`, + example: { + options: { + coverage: true, + }, + }, + }, + }, + }; + targetGroup.push(targetName); + } } } } @@ -312,9 +415,10 @@ async function buildJestTargets( function getInputs( namedInputs: NxJsonConfiguration['namedInputs'], - jestConfig: { preset?: string }, + preset: string, projectRoot: string, - workspaceRoot: string + workspaceRoot: string, + disableJestRuntime?: boolean ): TargetConfiguration['inputs'] { const inputs: TargetConfiguration['inputs'] = [ ...('production' in namedInputs @@ -323,11 +427,9 @@ function getInputs( ]; const externalDependencies = ['jest']; - const presetInput = resolvePresetInput( - jestConfig.preset, - projectRoot, - workspaceRoot - ); + const presetInput = disableJestRuntime + ? resolvePresetInputWithoutJestResolver(preset, projectRoot) + : resolvePresetInputWithJestResolver(preset, projectRoot, workspaceRoot); if (presetInput) { if ( typeof presetInput !== 'string' && @@ -343,21 +445,33 @@ function getInputs( return inputs; } +function resolvePresetInputWithoutJestResolver( + presetValue: string | undefined, + projectRoot: 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] }; + } + + return presetValue.startsWith('..') + ? join('{workspaceRoot}', join(projectRoot, presetValue)) + : join('{projectRoot}', presetValue); +} // preset resolution adapted from: // https://github.com/jestjs/jest/blob/c54bccd657fb4cf060898717c09f633b4da3eec4/packages/jest-config/src/normalize.ts#L122 -function resolvePresetInput( +function resolvePresetInputWithJestResolver( presetValue: string | undefined, projectRoot: string, workspaceRoot: string ): TargetConfiguration['inputs'][number] | null { - if (!presetValue) { - return null; - } + if (!presetValue) return null; - const { replaceRootDirInPath } = requireJestUtil< - typeof import('jest-config') - >('jest-config', projectRoot, workspaceRoot); let presetPath = replaceRootDirInPath(projectRoot, presetValue); const isNpmPackage = !presetValue.startsWith('.') && !isAbsolute(presetPath); presetPath = presetPath.startsWith('.') @@ -385,9 +499,18 @@ function resolvePresetInput( : 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('')) { + return filePath; + } + return resolve(rootDir, normalize(`./${filePath.slice(''.length)}`)); +} + function getOutputs( projectRoot: string, - { globalConfig }: { globalConfig: Config.GlobalConfig }, + coverageDirectory: string | undefined, + outputFile: string | undefined, context: CreateNodesContext ): string[] { function getOutput(path: string): string { @@ -404,10 +527,7 @@ function getOutputs( const outputs = []; - for (const outputOption of [ - globalConfig.coverageDirectory, - globalConfig.outputFile, - ]) { + for (const outputOption of [coverageDirectory, outputFile]) { if (outputOption) { outputs.push(getOutput(outputOption)); } @@ -423,6 +543,7 @@ function normalizeOptions(options: JestPluginOptions): JestPluginOptions { } let resolvedJestPaths: Record; + function resolveJestPath(projectRoot: string, workspaceRoot: string): string { resolvedJestPaths ??= {}; if (resolvedJestPaths[projectRoot]) { @@ -437,6 +558,7 @@ function resolveJestPath(projectRoot: string, workspaceRoot: string): string { } let resolvedJestCorePaths: Record; + /** * Resolves a jest util package version that `jest` is using. */ @@ -459,3 +581,75 @@ function requireJestUtil( paths: [dirname(resolvedJestCorePaths[jestPath])], })); } + +async function getJestOption( + rawConfig: any, + absConfigFilePath: string, + optionName: string +): Promise { + if (rawConfig[optionName]) return rawConfig[optionName]; + + if (rawConfig.preset) { + const dir = dirname(absConfigFilePath); + const presetPath = resolve(dir, rawConfig.preset); + try { + const preset = await loadConfigFile(presetPath); + if (preset[optionName]) return preset[optionName]; + } catch { + throw new Error(`Could not load preset file: ${presetPath}`); + } + } + + return undefined; +} + +async function getTestPaths( + projectRoot: string, + rawConfig: any, + absConfigFilePath: string, + context: CreateNodesContext +): Promise { + const testMatch = await getJestOption( + rawConfig, + absConfigFilePath, + 'testMatch' + ); + if (testMatch) { + return await globWithWorkspaceContext( + context.workspaceRoot, + testMatch.map((pattern) => join(projectRoot, pattern)), + [] + ); + } else { + const testRegex = await getJestOption( + rawConfig, + absConfigFilePath, + 'testRegex' + ); + if (testRegex) { + const files: string[] = []; + const testRegexes = Array.isArray(rawConfig.testRegex) + ? rawConfig.testRegex.map((r: string) => new RegExp(r)) + : [new RegExp(rawConfig.testRegex)]; + const projectFiles = await getFilesInDirectoryUsingContext( + context.workspaceRoot, + projectRoot + ); + for (const file of projectFiles) { + if (testRegexes.some((r: RegExp) => r.test(file))) files.push(file); + } + return files; + } else { + // Default copied from https://github.com/jestjs/jest/blob/d1a2ed7/packages/jest-config/src/Defaults.ts#L84 + const defaultTestMatch = [ + '**/__tests__/**/*.?([mc])[jt]s?(x)', + '**/?(*.)+(spec|test).?([mc])[jt]s?(x)', + ]; + return await globWithWorkspaceContext( + context.workspaceRoot, + defaultTestMatch.map((pattern) => join(projectRoot, pattern)), + [] + ); + } + } +}