chore(devkit): add util for migrating to a plugin (#19942)

This commit is contained in:
Jason Jean 2023-10-31 14:21:17 -04:00 committed by GitHub
parent b7fc7192cf
commit bdde0ddc98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 749 additions and 33 deletions

View File

@ -0,0 +1,447 @@
import { Tree } from 'nx/src/generators/tree';
import { CreateNodes } from 'nx/src/utils/nx-plugin';
import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
import {
addProjectConfiguration,
readProjectConfiguration,
} from 'nx/src/generators/utils/project-configuration';
import { replaceProjectConfigurationsWithPlugin } from './replace-project-configuration-with-plugin';
describe('replaceProjectConfigurationsWithPlugin', () => {
let tree: Tree;
let createNodes: CreateNodes;
beforeEach(async () => {
tree = createTreeWithEmptyWorkspace();
tree.write('proj/file.txt', '');
createNodes = [
'proj/file.txt',
() => ({
projects: {
proj: {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: ['^build-base'],
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
configurations: {
production: {
configFile: 'file.prod.txt',
},
},
},
},
},
},
}),
];
});
it('should not update the target when it uses a different executor', async () => {
const buildTarget = {
executor: 'nx:run-script',
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
};
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: buildTarget,
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual(
buildTarget
);
});
describe('options', () => {
it('should be removed when there are no other options', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(
readProjectConfiguration(tree, 'proj').targets.build
).toBeUndefined();
});
it('should not be removed when there are other options', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
watch: false,
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
options: {
watch: false,
},
});
});
});
describe('inputs', () => {
it('should not be removed if there are additional inputs', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default', '{workspaceRoot}/file.txt'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
inputs: ['default', '^default', '{workspaceRoot}/file.txt'],
});
});
it('should not be removed if there are additional inputs which are objects', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: [
'default',
'^default',
{
env: 'HOME',
},
],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
inputs: [
'default',
'^default',
{
env: 'HOME',
},
],
});
});
it('should not be removed if there are less inputs', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
inputs: ['default'],
});
});
});
describe('outputs', () => {
it('should not be removed if there are additional outputs', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default'],
outputs: [
'{options.output}',
'{projectRoot}/outputs',
'{projectRoot}/more-outputs',
],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
outputs: [
'{options.output}',
'{projectRoot}/outputs',
'{projectRoot}/more-outputs',
],
});
});
it('should not be removed if there are less outputs', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
outputs: ['{options.output}'],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
outputs: ['{options.output}'],
});
});
});
describe('dependsOn', () => {
it('should be removed when it is the same', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: ['^build-base'],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(
readProjectConfiguration(tree, 'proj').targets.build
).toBeUndefined();
});
it('should not be removed when there are more dependent tasks', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: ['^build-base', 'prebuild'],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
dependsOn: ['^build-base', 'prebuild'],
});
});
it('should not be removed when there are less dependent tasks', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: [],
options: {
configFile: 'file.txt',
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
dependsOn: [],
});
});
});
describe('defaultConfiguration', () => {
it('should not be removed when the defaultConfiguration is different', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
options: {
configFile: 'file.txt',
},
defaultConfiguration: 'other',
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
defaultConfiguration: 'other',
});
});
});
describe('configurations', () => {
it('should not be removed when an additional configuration is defined', () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
options: {
configFile: 'file.txt',
},
configurations: {
other: {
configFile: 'other-file.txt',
},
},
},
},
});
replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
configurations: {
other: {
configFile: 'other-file.txt',
},
},
});
});
});
});

View File

@ -0,0 +1,206 @@
import type {
ProjectConfiguration,
TargetConfiguration,
} from 'nx/src/config/workspace-json-project-json';
import type { Tree } from 'nx/src/generators/tree';
import type { CreateNodes } from 'nx/src/utils/nx-plugin';
import { requireNx } from '../../nx';
const {
readNxJson,
updateNxJson,
glob,
hashObject,
findProjectForPath,
readProjectConfiguration,
updateProjectConfiguration,
} = requireNx();
export function replaceProjectConfigurationsWithPlugin<T = unknown>(
tree: Tree,
rootMappings: Map<string, string>,
pluginPath: string,
createNodes: CreateNodes<T>,
pluginOptions: T
): void {
const nxJson = readNxJson(tree);
const hasPlugin = nxJson.plugins?.some((p) =>
typeof p === 'string' ? p === pluginPath : p.plugin === pluginPath
);
if (hasPlugin) {
return;
}
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: pluginPath,
options: pluginOptions,
});
updateNxJson(tree, nxJson);
const [pluginGlob, createNodesFunction] = createNodes;
const configFiles = glob(tree, [pluginGlob]);
for (const configFile of configFiles) {
try {
const projectName = findProjectForPath(configFile, rootMappings);
const projectConfig = readProjectConfiguration(tree, projectName);
const nodes = createNodesFunction(configFile, pluginOptions, {
workspaceRoot: tree.root,
nxJsonConfiguration: readNxJson(tree),
});
const node = nodes.projects[Object.keys(nodes.projects)[0]];
for (const [targetName, targetConfig] of Object.entries(node.targets)) {
const targetFromProjectConfig = projectConfig.targets[targetName];
if (targetFromProjectConfig.executor !== targetConfig.executor) {
continue;
}
const targetFromCreateNodes = node.targets[targetName];
removeConfigurationDefinedByPlugin(
targetName,
targetFromProjectConfig,
targetFromCreateNodes,
projectConfig
);
}
updateProjectConfiguration(tree, projectName, projectConfig);
} catch (e) {
console.error(e);
}
}
}
function removeConfigurationDefinedByPlugin<T>(
targetName: string,
targetFromProjectConfig: TargetConfiguration<T>,
targetFromCreateNodes: TargetConfiguration<T>,
projectConfig: ProjectConfiguration
) {
// Executor
delete targetFromProjectConfig.executor;
// Default Configuration
if (
targetFromProjectConfig.defaultConfiguration ===
targetFromCreateNodes.defaultConfiguration
) {
delete targetFromProjectConfig.defaultConfiguration;
}
// Cache
if (targetFromProjectConfig.cache === targetFromCreateNodes.cache) {
delete targetFromProjectConfig.cache;
}
// Depends On
if (
targetFromProjectConfig.dependsOn &&
shouldRemoveArrayProperty(
targetFromProjectConfig.dependsOn,
targetFromCreateNodes.dependsOn
)
) {
delete targetFromProjectConfig.dependsOn;
}
// Outputs
if (
targetFromProjectConfig.outputs &&
shouldRemoveArrayProperty(
targetFromProjectConfig.outputs,
targetFromCreateNodes.outputs
)
) {
delete targetFromProjectConfig.outputs;
}
// Inputs
if (
targetFromProjectConfig.inputs &&
shouldRemoveArrayProperty(
targetFromProjectConfig.inputs,
targetFromCreateNodes.inputs
)
) {
delete targetFromProjectConfig.inputs;
}
// Options
for (const [optionName, optionValue] of Object.entries(
targetFromProjectConfig.options ?? {}
)) {
if (targetFromCreateNodes.options[optionName] === optionValue) {
delete targetFromProjectConfig.options[optionName];
}
}
if (Object.keys(targetFromProjectConfig.options).length === 0) {
delete targetFromProjectConfig.options;
}
// Configurations
for (const [configName, configOptions] of Object.entries(
targetFromProjectConfig.configurations ?? {}
)) {
for (const [optionName, optionValue] of Object.entries(configOptions)) {
if (
targetFromCreateNodes.configurations?.[configName]?.[optionName] ===
optionValue
) {
delete targetFromProjectConfig.configurations[configName][optionName];
}
}
if (Object.keys(configOptions).length === 0) {
delete targetFromProjectConfig.configurations[configName];
}
}
if (Object.keys(targetFromProjectConfig.configurations ?? {}).length === 0) {
delete targetFromProjectConfig.configurations;
}
if (Object.keys(targetFromProjectConfig).length === 0) {
delete projectConfig.targets[targetName];
}
}
function shouldRemoveArrayProperty(
arrayValuesFromProjectConfiguration: (object | string)[],
arrayValuesFromCreateNodes: (object | string)[]
) {
const setOfArrayValuesFromProjectConfiguration = new Set(
arrayValuesFromProjectConfiguration
);
loopThroughArrayValuesFromCreateNodes: for (const arrayValueFromCreateNodes of arrayValuesFromCreateNodes) {
if (typeof arrayValueFromCreateNodes === 'string') {
if (
!setOfArrayValuesFromProjectConfiguration.has(arrayValueFromCreateNodes)
) {
// If the inputs from the project configuration is missing an input from createNodes it was removed
return false;
} else {
setOfArrayValuesFromProjectConfiguration.delete(
arrayValueFromCreateNodes
);
}
} else {
for (const arrayValue of setOfArrayValuesFromProjectConfiguration.values()) {
if (
typeof arrayValue !== 'string' &&
hashObject(arrayValue) === hashObject(arrayValueFromCreateNodes)
) {
setOfArrayValuesFromProjectConfiguration.delete(arrayValue);
// Continue the outer loop, breaking out of this loop
continue loopThroughArrayValuesFromCreateNodes;
}
}
// If an input was not matched, that means the input was removed
return false;
}
}
// If there are still inputs in the project configuration, they have added additional inputs
return setOfArrayValuesFromProjectConfiguration.size === 0;
}

View File

@ -14,6 +14,7 @@ export { sortObjectByKeys } from './utils/object-sort';
export { stripIndent } from './utils/logger'; export { stripIndent } from './utils/logger';
export { readModulePackageJson } from './utils/package-json'; export { readModulePackageJson } from './utils/package-json';
export { splitByColons } from './utils/split-target'; export { splitByColons } from './utils/split-target';
export { hashObject } from './hasher/file-hasher';
export { export {
createProjectRootMappingsFromProjectConfigurations, createProjectRootMappingsFromProjectConfigurations,
findProjectForPath, findProjectForPath,

View File

@ -6,7 +6,7 @@ exports[`create-nodes-plugin/generator generator should run successfully 1`] = `
CreateNodesContext, CreateNodesContext,
TargetConfiguration, TargetConfiguration,
} from '@nx/devkit'; } from '@nx/devkit';
import { basename, dirname, extname, join, resolve } from "path"; import { basename, dirname, extname, join, resolve } from 'path';
import { registerTsProject } from '@nx/js/src/internal'; import { registerTsProject } from '@nx/js/src/internal';
import { getRootTsConfigPath } from '@nx/js'; import { getRootTsConfigPath } from '@nx/js';
@ -45,7 +45,7 @@ export const createNodes: CreateNodes<EslintPluginOptions> = [
configFilePath, configFilePath,
projectRoot, projectRoot,
options, options,
context, context
), ),
}, },
}, },
@ -57,12 +57,9 @@ function buildEslintTargets(
configFilePath: string, configFilePath: string,
projectRoot: string, projectRoot: string,
options: EslintPluginOptions, options: EslintPluginOptions,
context: CreateNodesContext, context: CreateNodesContext
) { ) {
const eslintConfig = getEslintConfig( const eslintConfig = getEslintConfig(configFilePath, context);
configFilePath,
context
);
const targetDefaults = readTargetDefaultsForTarget( const targetDefaults = readTargetDefaultsForTarget(
options.targetName, options.targetName,
@ -72,10 +69,7 @@ function buildEslintTargets(
const namedInputs = getNamedInputs(projectRoot, context); const namedInputs = getNamedInputs(projectRoot, context);
const targets: Record< const targets: Record<string, TargetConfiguration<ExecutorOptions>> = {};
string,
TargetConfiguration<ExecutorOptions>
> = {};
const baseTargetConfig: TargetConfiguration<ExecutorOptions> = { const baseTargetConfig: TargetConfiguration<ExecutorOptions> = {
executor: 'executorName', executor: 'executorName',
@ -92,9 +86,7 @@ function buildEslintTargets(
targetDefaults?.inputs ?? 'production' in namedInputs targetDefaults?.inputs ?? 'production' in namedInputs
? ['default', '^production'] ? ['default', '^production']
: ['default', '^default'], : ['default', '^default'],
outputs: outputs: targetDefaults?.outputs ?? getOutputs(projectRoot),
targetDefaults?.outputs ??
getOutputs(projectRoot),
options: { options: {
...baseTargetConfig.options, ...baseTargetConfig.options,
}, },
@ -129,9 +121,7 @@ function getEslintConfig(
return module.default ?? module; return module.default ?? module;
} }
function getOutputs( function getOutputs(projectRoot: string): string[] {
projectRoot: string,
): string[] {
function getOutput(path: string): string { function getOutput(path: string): string {
if (path.startsWith('..')) { if (path.startsWith('..')) {
return join('{workspaceRoot}', join(projectRoot, path)); return join('{workspaceRoot}', join(projectRoot, path));
@ -179,7 +169,7 @@ describe('@nx/eslint/plugin', () => {
}); });
it('should create nodes', () => { it('should create nodes', () => {
mockEslintConfig({}) mockEslintConfig({});
const nodes = createNodesFunction( const nodes = createNodesFunction(
'TODO', 'TODO',
{ {
@ -192,10 +182,7 @@ describe('@nx/eslint/plugin', () => {
}); });
}); });
function mockEslintConfig(config: any) {
function mockEslintConfig(
config: any
) {
jest.mock( jest.mock(
'TODO', 'TODO',
() => ({ () => ({
@ -208,3 +195,30 @@ function mockEslintConfig(
} }
" "
`; `;
exports[`create-nodes-plugin/generator generator should run successfully 3`] = `
"import { formatFiles, getProjects, Tree } from '@nx/devkit';
import { createNodes } from '../../plugins/plugin';
import { createProjectRootMappingsFromProjectConfigurations } from 'nx/src/project-graph/utils/find-project-for-path';
import { replaceProjectConfigurationsWithPlugin } from '@nx/devkit/src/utils/replace-project-configuration-with-plugin';
export default async function update(tree: Tree) {
const proj = Object.fromEntries(getProjects(tree).entries());
const rootMappings = createProjectRootMappingsFromProjectConfigurations(proj);
replaceProjectConfigurationsWithPlugin(
tree,
rootMappings,
'@nx/eslint/plugin',
createNodes,
{
targetName: 'TODO',
}
);
await formatFiles(tree);
}
"
`;

View File

@ -0,0 +1,23 @@
import { formatFiles, getProjects, Tree } from '@nx/devkit';
import { createNodes } from '../../plugins/plugin';
import { createProjectRootMappingsFromProjectConfigurations } from 'nx/src/project-graph/utils/find-project-for-path';
import { replaceProjectConfigurationsWithPlugin } from '@nx/devkit/src/utils/replace-project-configuration-with-plugin';
export default async function update(tree: Tree) {
const proj = Object.fromEntries(getProjects(tree).entries());
const rootMappings = createProjectRootMappingsFromProjectConfigurations(proj);
replaceProjectConfigurationsWithPlugin(
tree,
rootMappings,
'@nx/<%= dirName %>/plugin',
createNodes,
{
targetName: 'TODO',
}
);
await formatFiles(tree);
}

View File

@ -1,25 +1,43 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Tree } from '@nx/devkit'; import { addProjectConfiguration, Tree, writeJson } from '@nx/devkit';
import { generatorGenerator, GeneratorGeneratorSchema } from './generator'; import { generatorGenerator } from './generator';
import { setCwd } from '@nx/devkit/internal-testing-utils';
describe('create-nodes-plugin/generator generator', () => { describe('create-nodes-plugin/generator generator', () => {
let tree: Tree; let tree: Tree;
const options: GeneratorGeneratorSchema = {};
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyWorkspace(); tree = createTreeWithEmptyWorkspace();
addProjectConfiguration(tree, 'eslint', {
root: 'packages/eslint',
targets: {
build: {},
},
});
writeJson(tree, 'packages/eslint/package.json', {});
jest.spyOn(process, 'cwd').mockReturnValue('/virtual/packages/eslint'); jest.spyOn(process, 'cwd').mockReturnValue('/virtual/packages/eslint');
setCwd('packages/eslint');
}); });
it('should run successfully', async () => { it('should run successfully', async () => {
await generatorGenerator(tree, options); await generatorGenerator(tree);
expect( expect(
tree.read('packages/eslint/src/plugins/plugin.ts').toString() tree.read('packages/eslint/src/plugins/plugin.ts').toString()
).toMatchSnapshot(); ).toMatchSnapshot();
expect( expect(
tree.read('packages/eslint/src/plugins/plugin.spec.ts').toString() tree.read('packages/eslint/src/plugins/plugin.spec.ts').toString()
).toMatchSnapshot(); ).toMatchSnapshot();
expect(
tree
.read(
'packages/eslint/src/migrations/update-17-2-0/add-eslint-plugin.ts'
)
.toString()
).toMatchSnapshot();
}); });
}); });

View File

@ -1,20 +1,27 @@
import { generateFiles, names, Tree } from '@nx/devkit'; import { formatFiles, generateFiles, names, Tree } from '@nx/devkit';
import { basename, join, relative } from 'path'; import { basename, join, relative } from 'path';
import migrationGenerator from '@nx/plugin/src/generators/migration/migration';
export interface GeneratorGeneratorSchema {} export async function generatorGenerator(tree: Tree) {
export async function generatorGenerator(
tree: Tree,
options: GeneratorGeneratorSchema
) {
const cwd = process.cwd(); const cwd = process.cwd();
const { className, propertyName } = names(basename(cwd)); const { className, propertyName } = names(basename(cwd));
await migrationGenerator(tree, {
name: `add-${basename(cwd)}-plugin`,
packageVersion: '17.2.0-beta.0',
description: `Add @nx/${basename(cwd)}/plugin`,
nameAndDirectoryFormat: 'as-provided',
directory: `src/migrations/update-17-2-0`,
skipFormat: true,
});
generateFiles(tree, join(__dirname, 'files'), relative(tree.root, cwd), { generateFiles(tree, join(__dirname, 'files'), relative(tree.root, cwd), {
dirName: basename(cwd), dirName: basename(cwd),
className, className,
propertyName, propertyName,
}); });
await formatFiles(tree);
} }
export default generatorGenerator; export default generatorGenerator;