feat(testing): add convert-to-inferred migration generator for jest (#26259)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

## Current Behavior
<!-- This is the behavior we have today -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

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

Fixes #
This commit is contained in:
Leosvel Pérez Espinosa 2024-06-18 14:59:30 +02:00 committed by GitHub
parent e5d7805d4b
commit df3c7522ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1541 additions and 7 deletions

View File

@ -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,

View File

@ -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"

View File

@ -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",

View File

@ -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"
}

View File

@ -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)

View File

@ -35,7 +35,7 @@ type PostTargetTransformer = (
tree: Tree,
projectDetails: { projectName: string; root: string },
inferredTargetConfiguration: TargetConfiguration
) => TargetConfiguration;
) => TargetConfiguration | Promise<TargetConfiguration>;
type SkipTargetFilter = (
targetConfiguration: TargetConfiguration
) => [boolean, string];
@ -85,7 +85,7 @@ class ExecutorToPluginMigrator<T> {
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<T> {
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<T> {
});
}
#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<T> {
this.#mergeInputs(projectTarget, createdTarget);
}
projectTarget = this.#postTargetTransformer(
projectTarget = await this.#postTargetTransformer(
projectTarget,
this.tree,
{ projectName, root: projectFromGraph.data.root },

View File

@ -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,

View File

@ -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`."
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<JestPluginOptions>(
tree,
projectGraph,
'@nx/jest:jest',
'@nx/jest/plugin',
(targetName) => ({ targetName }),
postTargetTransformer,
createNodesV2,
options.project
);
const migratedProjectsLegacy =
await migrateExecutorToPlugin<JestPluginOptions>(
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<TargetConfiguration> {
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<string[]> {
// 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(
<Config.Argv>{ 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('<rootDir>')
? posix.join(workspaceRoot, projectRoot, f.slice('<rootDir>'.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, '**');
}

View File

@ -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
}
}
}