diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 6380a6e27e..c25f96d6d2 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -7749,6 +7749,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/jest/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 97326f5378..95d322a4a7 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -1130,6 +1130,15 @@ "originalFilePath": "/packages/jest/src/generators/configuration/schema.json", "path": "/nx-api/jest/generators/configuration", "type": "generator" + }, + "/nx-api/jest/generators/convert-to-inferred": { + "description": "Convert existing Jest project(s) using `@nx/jest:jest` executor to use `@nx/jest/plugin`.", + "file": "generated/packages/jest/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/jest/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/jest/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/jest" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 0031c2ff4d..8cb59936ab 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -1113,6 +1113,15 @@ "originalFilePath": "/packages/jest/src/generators/configuration/schema.json", "path": "jest/generators/configuration", "type": "generator" + }, + { + "description": "Convert existing Jest project(s) using `@nx/jest:jest` executor to use `@nx/jest/plugin`.", + "file": "generated/packages/jest/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/jest/src/generators/convert-to-inferred/schema.json", + "path": "jest/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/jest/generators/convert-to-inferred.json b/docs/generated/packages/jest/generators/convert-to-inferred.json new file mode 100644 index 0000000000..33c96f2139 --- /dev/null +++ b/docs/generated/packages/jest/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxJestConvertToInferred", + "description": "Convert existing Jest project(s) using `@nx/jest:jest` executor to use `@nx/jest/plugin`.", + "title": "Convert Jest project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/jest:jest` executor to use `@nx/jest/plugin`. If not provided, all projects using the `@nx/jest:jest` executor will be converted.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Jest project(s) using `@nx/jest:jest` executor to use `@nx/jest/plugin`.", + "implementation": "/packages/jest/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/jest/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index ca75c6e88b..5c492e7592 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -452,6 +452,7 @@ - [generators](/nx-api/jest/generators) - [init](/nx-api/jest/generators/init) - [configuration](/nx-api/jest/generators/configuration) + - [convert-to-inferred](/nx-api/jest/generators/convert-to-inferred) - [js](/nx-api/js) - [documents](/nx-api/js/documents) - [Overview](/nx-api/js/documents/overview) diff --git a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts index 147bb24107..ee1b647c2e 100644 --- a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts +++ b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts @@ -35,7 +35,7 @@ type PostTargetTransformer = ( tree: Tree, projectDetails: { projectName: string; root: string }, inferredTargetConfiguration: TargetConfiguration -) => TargetConfiguration; +) => TargetConfiguration | Promise; type SkipTargetFilter = ( targetConfiguration: TargetConfiguration ) => [boolean, string]; @@ -85,7 +85,7 @@ class ExecutorToPluginMigrator { await this.#init(); if (this.#targetAndProjectsToMigrate.size > 0) { for (const targetName of this.#targetAndProjectsToMigrate.keys()) { - this.#migrateTarget(targetName); + await this.#migrateTarget(targetName); } await this.#addPlugins(); } @@ -105,12 +105,12 @@ class ExecutorToPluginMigrator { await this.#getCreateNodesResults(); } - #migrateTarget(targetName: string) { + async #migrateTarget(targetName: string) { const include: string[] = []; for (const projectName of this.#targetAndProjectsToMigrate.get( targetName )) { - include.push(this.#migrateProject(projectName, targetName)); + include.push(await this.#migrateProject(projectName, targetName)); } this.#pluginToAddForTarget.set(targetName, { @@ -120,7 +120,7 @@ class ExecutorToPluginMigrator { }); } - #migrateProject(projectName: string, targetName: string) { + async #migrateProject(projectName: string, targetName: string) { const projectFromGraph = this.#projectGraph.nodes[projectName]; const projectConfig = readProjectConfiguration(this.tree, projectName); @@ -141,7 +141,7 @@ class ExecutorToPluginMigrator { this.#mergeInputs(projectTarget, createdTarget); } - projectTarget = this.#postTargetTransformer( + projectTarget = await this.#postTargetTransformer( projectTarget, this.tree, { projectName, root: projectFromGraph.data.root }, diff --git a/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts index d7a449b9cb..394ca3bad8 100644 --- a/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts +++ b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts @@ -1,4 +1,5 @@ -import type { TargetConfiguration } from 'nx/src/devkit-exports'; +import { relative, resolve } from 'node:path/posix'; +import { workspaceRoot, type TargetConfiguration } from 'nx/src/devkit-exports'; import { interpolate } from 'nx/src/devkit-internals'; /** @@ -128,6 +129,24 @@ export function processTargetOutputs( target.outputs = targetOutputs; } +export function toProjectRelativePath( + path: string, + projectRoot: string +): string { + if (projectRoot === '.') { + // workspace and project root are the same, we add a leading './' which is + // required by some tools (e.g. Jest) + return path.startsWith('.') ? path : `./${path}`; + } + + const relativePath = relative( + resolve(workspaceRoot, projectRoot), + resolve(workspaceRoot, path) + ); + + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; +} + function updateOutputRenamingOption( output: string, option: string, diff --git a/packages/jest/generators.json b/packages/jest/generators.json index b3e9194d48..d3d0f22772 100644 --- a/packages/jest/generators.json +++ b/packages/jest/generators.json @@ -14,6 +14,11 @@ "schema": "./src/generators/configuration/schema.json", "description": "Add Jest configuration to a project.", "hidden": true + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Jest project(s) using `@nx/jest:jest` executor to use `@nx/jest/plugin`." } } } diff --git a/packages/jest/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/jest/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000..6a66a88ca8 --- /dev/null +++ b/packages/jest/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,1097 @@ +import { + addProjectConfiguration, + joinPathFragments, + readNxJson, + readProjectConfiguration, + updateNxJson, + writeJson, + type ExpandedPluginConfiguration, + type ProjectConfiguration, + type ProjectGraph, + type Tree, +} from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { join } from 'node:path'; +import { + getRelativeProjectJsonSchemaPath, + updateProjectConfiguration, +} from 'nx/src/generators/utils/project-configuration'; +import type { JestPluginOptions } from '../../plugins/plugin'; +import { convertToInferred } from './convert-to-inferred'; + +let fs: TempFs; +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(() => Promise.resolve(projectGraph)), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); + +function addProject(tree: Tree, name: string, project: ProjectConfiguration) { + addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface TestProjectOptions { + appName: string; + appRoot: string; + targetName: string; + legacyExecutor?: boolean; +} + +const defaultTestProjectOptions: TestProjectOptions = { + appName: 'app1', + appRoot: 'apps/app1', + targetName: 'test', + legacyExecutor: false, +}; + +function writeJestConfig( + tree: Tree, + projectRoot: string, + jestConfig: any | undefined +) { + jestConfig ??= { + coverageDirectory: `../../coverage/${projectRoot}`, + }; + const jestConfigContents = `module.exports = ${JSON.stringify(jestConfig)};`; + + tree.write(`${projectRoot}/jest.config.js`, jestConfigContents); + fs.createFileSync(`${projectRoot}/jest.config.js`, jestConfigContents); + jest.doMock( + join(fs.tempDir, projectRoot, 'jest.config.js'), + () => jestConfig, + { virtual: true } + ); +} + +function createTestProject( + tree: Tree, + opts: Partial = defaultTestProjectOptions, + extraTargetOptions?: any, + jestConfig?: any +) { + let projectOpts = { ...defaultTestProjectOptions, ...opts }; + const project: ProjectConfiguration = { + name: projectOpts.appName, + root: projectOpts.appRoot, + projectType: 'application', + targets: { + [projectOpts.targetName]: { + executor: projectOpts.legacyExecutor + ? '@nrwl/jest:jest' + : '@nx/jest:jest', + options: { + jestConfig: `${projectOpts.appRoot}/jest.config.js`, + ...extraTargetOptions, + }, + }, + }, + }; + + writeJestConfig(tree, projectOpts.appRoot, jestConfig); + + tree.write(`${projectOpts.appRoot}/src/app/test.spec.ts`, ''); + fs.createFileSync(`${projectOpts.appRoot}/src/app/test.spec.ts`, ''); + + addProject(tree, project.name, project); + fs.createFileSync( + `${projectOpts.appRoot}/project.json`, + JSON.stringify(project) + ); + return project; +} + +describe('Jest - Convert Executors To Plugin', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('jest'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.cleanup(); + jest.resetModules(); + }); + + describe('--project', () => { + it('should set up the jest plugin and migrate specified project by removing the target when it only has options that are removed', async () => { + const project = createTestProject(tree, undefined, { + tsConfig: `${defaultTestProjectOptions.appRoot}/tsconfig.spec.json`, + config: `${defaultTestProjectOptions.appRoot}/jest.config.js`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test).toBeUndefined(); + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const jestPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && + plugin.plugin === '@nx/jest/plugin' && + plugin.include?.length === 1 + ); + expect(jestPlugin).toBeTruthy(); + expect(jestPlugin.include).toEqual([`${project.root}/**/*`]); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should handle targets using the legacy package executor', async () => { + const project = createTestProject( + tree, + { legacyExecutor: true }, + { + tsConfig: `${defaultTestProjectOptions.appRoot}/tsconfig.spec.json`, + config: `${defaultTestProjectOptions.appRoot}/jest.config.js`, + } + ); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test).toBeUndefined(); + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const jestPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && + plugin.plugin === '@nx/jest/plugin' && + plugin.include?.length === 1 + ); + expect(jestPlugin).toBeTruthy(); + expect(jestPlugin.include).toEqual([`${project.root}/**/*`]); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should reuse existing plugin registration when the target name matches', async () => { + const project = createTestProject(tree, undefined, { + tsConfig: `${defaultTestProjectOptions.appRoot}/tsconfig.spec.json`, + config: `${defaultTestProjectOptions.appRoot}/jest.config.js`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/jest/plugin', + options: { targetName: defaultTestProjectOptions.targetName }, + }); + updateNxJson(tree, nxJson); + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test).toBeUndefined(); + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const jestPluginRegistrations = nxJsonPlugins.filter( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && plugin.plugin === '@nx/jest/plugin' + ); + expect(jestPluginRegistrations.length).toBe(1); + expect(jestPluginRegistrations[0].include).toBeUndefined(); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should add a new plugin registration including the migrated project when the target name is different than the existing registration', async () => { + const project = createTestProject( + tree, + { targetName: 'jest' }, + { + tsConfig: `${defaultTestProjectOptions.appRoot}/tsconfig.spec.json`, + config: `${defaultTestProjectOptions.appRoot}/jest.config.js`, + } + ); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/jest/plugin', + options: { targetName: 'test' }, + }); + updateNxJson(tree, nxJson); + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test).toBeUndefined(); + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const jestPluginRegistrations = nxJsonPlugins.filter( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && plugin.plugin === '@nx/jest/plugin' + ); + expect(jestPluginRegistrations.length).toBe(2); + expect(jestPluginRegistrations[0].options.targetName).toBe('test'); + expect(jestPluginRegistrations[0].include).toBeUndefined(); + expect(jestPluginRegistrations[1].options.targetName).toBe('jest'); + expect(jestPluginRegistrations[1].include).toEqual([ + `${project.root}/**/*`, + ]); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should merge target default options for the executor to the project target options', async () => { + const project = createTestProject(tree, undefined, { + tsConfig: `${defaultTestProjectOptions.appRoot}/tsconfig.spec.json`, + config: `${defaultTestProjectOptions.appRoot}/jest.config.js`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['@nx/jest:jest'] = { + options: { passWithNoTests: true }, + }; + updateNxJson(tree, nxJson); + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert passWithNoTests was merged into the project target options + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + passWithNoTests: true, + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should rename "codeCoverage" to "coverage"', async () => { + const project = createTestProject(tree, undefined, { + codeCoverage: true, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + coverage: true, + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should make "testFile" relative to the project root and turn it into "testPathPattern"', async () => { + const project = createTestProject(tree, undefined, { + testFile: `${defaultTestProjectOptions.appRoot}/src/app/test.spec.ts`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testPathPattern: 'src/app/test.spec.ts', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it.each([ + defaultTestProjectOptions.appRoot, + `${defaultTestProjectOptions.appRoot}/`, + `./${defaultTestProjectOptions.appRoot}`, + `./${defaultTestProjectOptions.appRoot}/`, + ])( + 'should make "testFile" a catch-all wildcard when it is set to the project root (%s)', + async (testFile) => { + const project = createTestProject(tree, undefined, { testFile }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testPathPattern: '.*', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + } + ); + + it('should make "testPathPattern" paths relative to the project root', async () => { + const project = createTestProject(tree, undefined, { + testPathPattern: [ + `${defaultTestProjectOptions.appRoot}/src/app/test1.spec.ts`, + `${defaultTestProjectOptions.appRoot}/src/app/test2.spec.ts`, + ], + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testPathPattern: '"src/app/test1.spec.ts|src/app/test2.spec.ts"', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it.each([ + [[defaultTestProjectOptions.appRoot]], + [[`${defaultTestProjectOptions.appRoot}/`]], + [[`./${defaultTestProjectOptions.appRoot}`]], + [[`./${defaultTestProjectOptions.appRoot}/`]], + ])( + 'should make "testPathPattern" a catch-all wildcard when it is set to the project root (%s)', + async (testPathPattern) => { + const project = createTestProject(tree, undefined, { testPathPattern }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testPathPattern: '.*', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + } + ); + + it('should merge "testFile" and "testPathPattern" paths', async () => { + const project = createTestProject(tree, undefined, { + testFile: `${defaultTestProjectOptions.appRoot}/src/app/test1.spec.ts`, + testPathPattern: [ + `${defaultTestProjectOptions.appRoot}/src/app/test2.spec.ts`, + `${defaultTestProjectOptions.appRoot}/src/app/test3.spec.ts`, + ], + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testPathPattern: + '"src/app/test1.spec.ts|src/app/test2.spec.ts|src/app/test3.spec.ts"', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should make "testPathIgnorePatterns" paths relative to the project root', async () => { + const project = createTestProject(tree, undefined, { + testPathIgnorePatterns: [ + 'node_modules', + `${defaultTestProjectOptions.appRoot}/src/app/ignore-me/test.spec.ts`, + ], + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testPathIgnorePatterns: '"node_modules|src/app/ignore-me/test.spec.ts"', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it.each([ + [[defaultTestProjectOptions.appRoot]], + [[`${defaultTestProjectOptions.appRoot}/`]], + [[`./${defaultTestProjectOptions.appRoot}`]], + [[`./${defaultTestProjectOptions.appRoot}/`]], + ])( + 'should make "testPathIgnorePatterns" a catch-all wildcard when it is set to the project root (%s)', + async (testPathIgnorePatterns) => { + const project = createTestProject(tree, undefined, { + testPathIgnorePatterns, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testPathIgnorePatterns: '.*', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + } + ); + + it('should replace the project root in "testMatch" glob patterns', async () => { + const project = createTestProject(tree, undefined, { + testMatch: [ + `**/${defaultTestProjectOptions.appRoot}/**/?(*.)+(module.)(spec|test).[jt]s?(x)`, + `**/${defaultTestProjectOptions.appRoot}/src/__tests__/**/*.[jt]s?(x)`, + ], + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + testMatch: + '"**/?(*.)+(module.)(spec|test).[jt]s?(x)" "**/src/__tests__/**/*.[jt]s?(x)"', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should make "findRelatedTests" paths relative to the project root and a space separated list', async () => { + const project = createTestProject(tree, undefined, { + findRelatedTests: [ + `${defaultTestProjectOptions.appRoot}/src/app/test1.spec.ts`, + `${defaultTestProjectOptions.appRoot}/src/app/test2.spec.ts`, + ].join(','), + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + args: [ + '--findRelatedTests ./src/app/test1.spec.ts ./src/app/test2.spec.ts', + ], + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should merge "setupFile" with the "setupFilesAfterEnv" option values', async () => { + const project = createTestProject(tree, undefined, { + setupFile: `${defaultTestProjectOptions.appRoot}/src/test-setup.ts`, + setupFilesAfterEnv: [ + `/src/test-setup2.ts`, + `./src/test-setup3.ts`, + ], + }); + fs.createFilesSync({ + 'apps/app1/src/test-setup.ts': '', + 'apps/app1/src/test-setup2.ts': '', + 'apps/app1/src/test-setup3.ts': '', + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + setupFilesAfterEnv: + '"./src/test-setup2.ts" "./src/test-setup3.ts" "./src/test-setup.ts"', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should merge "setupFile" with the "setupFilesAfterEnv" config value', async () => { + const project = createTestProject( + tree, + undefined, + { + setupFile: `${defaultTestProjectOptions.appRoot}/src/test-setup.ts`, + }, + { + setupFilesAfterEnv: [ + `/src/test-setup2.ts`, + `/src/test-setup3.ts`, + ], + } + ); + fs.createFilesSync({ + 'apps/app1/src/test-setup.ts': '', + 'apps/app1/src/test-setup2.ts': '', + 'apps/app1/src/test-setup3.ts': '', + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options).toStrictEqual({ + setupFilesAfterEnv: + '"./src/test-setup2.ts" "./src/test-setup3.ts" "./src/test-setup.ts"', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should delete "setupFile" when it is already set in the jest config', async () => { + const project = createTestProject( + tree, + undefined, + { + setupFile: `${defaultTestProjectOptions.appRoot}/src/test-setup.ts`, + }, + { + setupFilesAfterEnv: [ + `/src/test-setup.ts`, // this resolves to the same path as the setupFile + `/src/test-setup2.ts`, + ], + } + ); + fs.createFilesSync({ + 'apps/app1/src/test-setup.ts': '', + 'apps/app1/src/test-setup2.ts': '', + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test).toBeUndefined(); // setupFile was removed and there are no other options, so the target is removed + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should make "ouputFile" relative to the project root', async () => { + const project = createTestProject(tree, undefined, { + outputFile: `test-results/${defaultTestProjectOptions.appRoot}/test-results.json`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options.outputFile).toBe( + `../../test-results/${project.root}/test-results.json` + ); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should make "coverageDirectory" relative to the project root', async () => { + const project = createTestProject(tree, undefined, { + coverageDirectory: `coverage/${defaultTestProjectOptions.appRoot}`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.options.coverageDirectory).toBe( + `../../coverage/${project.root}` + ); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should remove inputs when they are inferred', async () => { + const project = createTestProject(tree, undefined, { + codeCoverage: true, + }); + project.targets.test.inputs = ['default', '^default']; + updateProjectConfiguration(tree, project.name, project); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.inputs).toBeUndefined(); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should add external dependencies input from inferred task', async () => { + const project = createTestProject(tree, undefined, { + codeCoverage: true, + }); + project.targets.test.inputs = [ + 'default', + '^default', + '{workspaceRoot}/some-file.ts', + ]; + updateProjectConfiguration(tree, project.name, project); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.inputs).toStrictEqual([ + 'default', + '^default', + '{workspaceRoot}/some-file.ts', + { externalDependencies: ['jest'] }, + ]); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should merge external dependencies input from inferred task', async () => { + const project = createTestProject(tree, undefined, { + codeCoverage: true, + }); + project.targets.test.inputs = [ + 'default', + '^default', + '{workspaceRoot}/some-file.ts', + { externalDependencies: ['some-external-dep'] }, + ]; + updateProjectConfiguration(tree, project.name, project); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.inputs).toStrictEqual([ + 'default', + '^default', + '{workspaceRoot}/some-file.ts', + { externalDependencies: ['some-external-dep', 'jest'] }, + ]); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should not duplicate already existing external dependencies input', async () => { + const project = createTestProject(tree, undefined, { + codeCoverage: true, + }); + project.targets.test.inputs = [ + 'default', + '^default', + '{workspaceRoot}/some-file.ts', + { externalDependencies: ['jest', 'some-external-dep'] }, + ]; + updateProjectConfiguration(tree, project.name, project); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.inputs).toStrictEqual([ + 'default', + '^default', + '{workspaceRoot}/some-file.ts', + { externalDependencies: ['jest', 'some-external-dep'] }, + ]); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should delete outputs when they are inferred', async () => { + const project = createTestProject( + tree, + undefined, + { coverage: true }, + { + coverageDirectory: `../../coverage/${defaultTestProjectOptions.appRoot}`, // the plugin will infer {workspaceRoot}/coverage/apps/app1 + } + ); + project.targets.test.outputs = [ + `{workspaceRoot}/coverage/{projectRoot}`, // this is equivalent to the inferred {workspaceRoot}/coverage/apps/app1 + ]; + updateProjectConfiguration(tree, project.name, project); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.outputs).toBeUndefined(); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + + it('should keep outputs adding any extra inferred outputs', async () => { + const project = createTestProject( + tree, + undefined, + { + outputFile: `test-results/${defaultTestProjectOptions.appRoot}/test-results.json`, + }, + { + coverageDirectory: `../../coverage/${defaultTestProjectOptions.appRoot}`, // the plugin will infer {workspaceRoot}/coverage/apps/app1 + } + ); + project.targets.test.outputs = [`{workspaceRoot}/{options.outputFile}`]; + updateProjectConfiguration(tree, project.name, project); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + const project2TestTarget = project2.targets.test; + + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // assert updated project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.test.outputs).toStrictEqual([ + `{projectRoot}/{options.outputFile}`, // updated to be relative to the project root + `{workspaceRoot}/coverage/${project.root}`, // added from the inferred outputs + ]); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toStrictEqual(project2TestTarget); + }); + }); + + describe('all projects', () => { + it('should migrate multiple projects using the jest executors', async () => { + const project1 = createTestProject(tree, undefined, { + tsConfig: `${defaultTestProjectOptions.appRoot}/tsconfig.spec.json`, + config: `${defaultTestProjectOptions.appRoot}/jest.config.js`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + legacyExecutor: true, + }); + const project3 = createTestProject(tree, { + appRoot: 'apps/project3', + appName: 'project3', + legacyExecutor: true, + }); + + await convertToInferred(tree, { skipFormat: true }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject1 = readProjectConfiguration(tree, project1.name); + expect(updatedProject1.targets.test).toBeUndefined(); + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toBeUndefined(); + const updatedProject3 = readProjectConfiguration(tree, project3.name); + expect(updatedProject3.targets.test).toBeUndefined(); + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const jestPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && plugin.plugin === '@nx/jest/plugin' + ); + expect(jestPlugin).toBeTruthy(); + expect(jestPlugin.include).toBeUndefined(); + }); + + it('should add new plugin registrations with "includes" set to the migrated projects grouped by different target names', async () => { + const project1 = createTestProject(tree, undefined, { + tsConfig: `${defaultTestProjectOptions.appRoot}/tsconfig.spec.json`, + config: `${defaultTestProjectOptions.appRoot}/jest.config.js`, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + legacyExecutor: true, + }); + const project3 = createTestProject(tree, { + appRoot: 'apps/project3', + appName: 'project3', + targetName: 'jest', + }); + + await convertToInferred(tree, { skipFormat: true }); + + // target only had options that are removed, assert no test target in converted project + const updatedProject1 = readProjectConfiguration(tree, project1.name); + expect(updatedProject1.targets.test).toBeUndefined(); + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.test).toBeUndefined(); + const updatedProject3 = readProjectConfiguration(tree, project3.name); + expect(updatedProject3.targets.test).toBeUndefined(); + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const jestPluginRegistrations = nxJsonPlugins.filter( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && plugin.plugin === '@nx/jest/plugin' + ); + expect(jestPluginRegistrations.length).toBe(2); + expect(jestPluginRegistrations[0].options.targetName).toBe('test'); + expect(jestPluginRegistrations[0].include).toEqual([ + `${project1.root}/**/*`, + `${project2.root}/**/*`, + ]); + expect(jestPluginRegistrations[1].options.targetName).toBe('jest'); + expect(jestPluginRegistrations[1].include).toEqual([ + `${project3.root}/**/*`, + ]); + }); + }); +}); diff --git a/packages/jest/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/jest/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000..de6beaea2e --- /dev/null +++ b/packages/jest/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,337 @@ +import type { Config } from '@jest/types'; +import { + createProjectGraphAsync, + formatFiles, + type TargetConfiguration, + type Tree, +} from '@nx/devkit'; +import { migrateExecutorToPlugin } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { + processTargetOutputs, + toProjectRelativePath, +} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { readConfig } from 'jest-config'; +import { join, normalize, posix } from 'node:path'; +import { createNodesV2, type JestPluginOptions } from '../../plugins/plugin'; +import { jestConfigExtensions } from '../../utils/config/config-file'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + const migratedProjectsModern = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nx/jest:jest', + '@nx/jest/plugin', + (targetName) => ({ targetName }), + postTargetTransformer, + createNodesV2, + options.project + ); + + const migratedProjectsLegacy = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nrwl/jest:jest', + '@nx/jest/plugin', + (targetName) => ({ targetName }), + postTargetTransformer, + createNodesV2, + options.project + ); + + const migratedProjects = + migratedProjectsModern.size + migratedProjectsLegacy.size; + + if (migratedProjects === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +async function postTargetTransformer( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTarget: TargetConfiguration +): Promise { + const jestConfigPath = jestConfigExtensions + .map((ext) => `jest.config.${ext}`) + .find((configFileName) => + tree.exists(posix.join(projectDetails.root, configFileName)) + ); + + if (target.options) { + await updateOptions( + target.options, + projectDetails.root, + tree.root, + jestConfigPath + ); + } + + if (target.configurations) { + for (const [configName, config] of Object.entries(target.configurations)) { + await updateOptions( + config, + projectDetails.root, + tree.root, + jestConfigPath + ); + + if (!Object.keys(config).length) { + delete target.configurations[configName]; + } + } + + if (!Object.keys(target.configurations).length) { + delete target.defaultConfiguration; + delete target.configurations; + } + + if ( + 'defaultConfiguration' in target && + !target.configurations?.[target.defaultConfiguration] + ) { + delete target.defaultConfiguration; + } + } + + if (target.outputs) { + processTargetOutputs(target, [], inferredTarget, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; +} + +export default convertToInferred; + +async function updateOptions( + targetOptions: any, + projectRoot: string, + workspaceRoot: string, + defaultJestConfigPath: string | undefined +) { + const jestConfigPath = targetOptions.jestConfig ?? defaultJestConfigPath; + // inferred targets are only identified after known files that Jest would + // pick up, so we can safely remove the config options + delete targetOptions.jestConfig; + delete targetOptions.config; + + // deprecated and unused + delete targetOptions.tsConfig; + + if ('codeCoverage' in targetOptions) { + targetOptions.coverage = targetOptions.codeCoverage; + delete targetOptions.codeCoverage; + } + + const testPathPatterns: string[] = []; + if ('testFile' in targetOptions) { + testPathPatterns.push( + toProjectRelativeRegexPath(targetOptions.testFile, projectRoot) + ); + delete targetOptions.testFile; + } + + if ('testPathPattern' in targetOptions) { + testPathPatterns.push( + ...targetOptions.testPathPattern.map((pattern: string) => + toProjectRelativeRegexPath(pattern, projectRoot) + ) + ); + } + + if (testPathPatterns.length > 1) { + targetOptions.testPathPattern = `\"${testPathPatterns.join('|')}\"`; + } else if (testPathPatterns.length === 1) { + targetOptions.testPathPattern = testPathPatterns[0]; + } + + if ('testPathIgnorePatterns' in targetOptions) { + if (targetOptions.testPathIgnorePatterns.length > 1) { + targetOptions.testPathIgnorePatterns = `\"${targetOptions.testPathIgnorePatterns + .map((pattern: string) => + toProjectRelativeRegexPath(pattern, projectRoot) + ) + .join('|')}\"`; + } else if (targetOptions.testPathIgnorePatterns.length === 1) { + targetOptions.testPathIgnorePatterns = toProjectRelativeRegexPath( + targetOptions.testPathIgnorePatterns[0], + projectRoot + ); + } + } + + if ('testMatch' in targetOptions) { + targetOptions.testMatch = targetOptions.testMatch + .map( + (pattern: string) => + `"${toProjectRelativeGlobPath(pattern, projectRoot)}"` + ) + .join(' '); + } + + if ('findRelatedTests' in targetOptions) { + // the executor accepts a comma-separated string, while jest accepts a space-separated string + const parsedSourceFiles = targetOptions.findRelatedTests + .split(',') + .map((s: string) => toProjectRelativePath(s.trim(), projectRoot)) + .join(' '); + targetOptions.args = [`--findRelatedTests ${parsedSourceFiles}`]; + delete targetOptions.findRelatedTests; + } + + if ('setupFile' in targetOptions) { + const setupFiles = await processSetupFiles( + targetOptions.setupFile, + targetOptions.setupFilesAfterEnv, + projectRoot, + workspaceRoot, + jestConfigPath + ); + if (setupFiles.length > 1) { + targetOptions.setupFilesAfterEnv = setupFiles + .map((sf) => `"${sf}"`) + .join(' '); + } else if (setupFiles.length === 1) { + targetOptions.setupFilesAfterEnv = setupFiles[0]; + } else { + // if there are no setup files, it means they are already defined in the + // jest config, so we can remove the option + delete targetOptions.setupFilesAfterEnv; + } + delete targetOptions.setupFile; + } + + if ('outputFile' in targetOptions) { + // update the output file to be relative to the project root + targetOptions.outputFile = toProjectRelativePath( + targetOptions.outputFile, + projectRoot + ); + } + if ('coverageDirectory' in targetOptions) { + // update the coverage directory to be relative to the project root + targetOptions.coverageDirectory = toProjectRelativePath( + targetOptions.coverageDirectory, + projectRoot + ); + } +} + +async function processSetupFiles( + setupFile: string, + setupFilesAfterEnv: string[] | undefined, + projectRoot: string, + workspaceRoot: string, + jestConfigPath: string | undefined +): Promise { + // the jest executor merges the setupFile with the setupFilesAfterEnv, so + // to keep the task working as before we resolve the setupFilesAfterEnv + // from the options or the jest config and add the setupFile to it + // https://github.com/nrwl/nx/blob/bdd3375256613340899f649eb800d22abcc9f507/packages/jest/src/executors/jest/jest.impl.ts#L107-L113 + const configSetupFilesAfterEnv: string[] = []; + if (jestConfigPath) { + const jestConfig = await readConfig( + { setupFilesAfterEnv }, + join(workspaceRoot, jestConfigPath) + ); + if (jestConfig.projectConfig.setupFilesAfterEnv) { + configSetupFilesAfterEnv.push( + ...jestConfig.projectConfig.setupFilesAfterEnv.map((file: string) => + toProjectRelativePath(file, projectRoot) + ) + ); + } + } + + if (!configSetupFilesAfterEnv.length) { + return [toProjectRelativePath(setupFile, projectRoot)]; + } + + if ( + isSetupFileInConfig( + configSetupFilesAfterEnv, + setupFile, + projectRoot, + workspaceRoot + ) + ) { + // the setupFile is already included in the setupFilesAfterEnv + return []; + } + + return [ + ...configSetupFilesAfterEnv, + toProjectRelativePath(setupFile, projectRoot), + ]; +} + +function isSetupFileInConfig( + setupFilesAfterEnv: string[], + setupFile: string, + projectRoot: string, + workspaceRoot: string +): boolean { + const normalizePath = (f: string) => + f.startsWith('') + ? posix.join(workspaceRoot, projectRoot, f.slice(''.length)) + : posix.join(workspaceRoot, projectRoot, f); + + const normalizedSetupFiles = new Set(setupFilesAfterEnv.map(normalizePath)); + + return normalizedSetupFiles.has( + normalizePath(toProjectRelativePath(setupFile, projectRoot)) + ); +} + +function toProjectRelativeRegexPath(path: string, projectRoot: string): string { + if (projectRoot === '.') { + // workspace and project root are the same, keep the path as is + return path; + } + + const normalizedRoot = normalize(projectRoot); + if ( + new RegExp(`^(?:\\.[\\/\\\\])?${normalizedRoot}(?:[\\/\\\\])?$`).test(path) + ) { + // path includes everything inside project root + return '.*'; + } + + const normalizedPath = normalize(path); + const startWithProjectRootRegex = new RegExp( + `^(?:\\.[\\/\\\\])?${normalizedRoot}[\\/\\\\]` + ); + + return startWithProjectRootRegex.test(normalizedPath) + ? normalizedPath.replace(startWithProjectRootRegex, '') + : path; +} + +function toProjectRelativeGlobPath(path: string, projectRoot: string): string { + if (projectRoot === '.') { + // workspace and project root are the same, keep the path as is + return path; + } + + // globs use forward slashes, so we make sure to normalize the path + const normalizedRoot = posix.normalize(projectRoot); + + return path + .replace(new RegExp(`\/${normalizedRoot}\/`), '/') + .replace(/\*\*\/\*\*/g, '**'); +} diff --git a/packages/jest/src/generators/convert-to-inferred/schema.json b/packages/jest/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000..bec855c088 --- /dev/null +++ b/packages/jest/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxJestConvertToInferred", + "description": "Convert existing Jest project(s) using `@nx/jest:jest` executor to use `@nx/jest/plugin`.", + "title": "Convert Jest project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/jest:jest` executor to use `@nx/jest/plugin`. If not provided, all projects using the `@nx/jest:jest` executor will be converted.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files.", + "default": false + } + } +}