Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(core): handle existing plugins failed with imported project #28893

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/generated/devkit/AggregateCreateNodesError.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ It allows Nx to recieve partial results and continue processing for better UX.
- [message](../../devkit/documents/AggregateCreateNodesError#message): string
- [name](../../devkit/documents/AggregateCreateNodesError#name): string
- [partialResults](../../devkit/documents/AggregateCreateNodesError#partialresults): CreateNodesResultV2
- [pluginIndex](../../devkit/documents/AggregateCreateNodesError#pluginindex): number
- [stack](../../devkit/documents/AggregateCreateNodesError#stack): string
- [prepareStackTrace](../../devkit/documents/AggregateCreateNodesError#preparestacktrace): Function
- [stackTraceLimit](../../devkit/documents/AggregateCreateNodesError#stacktracelimit): number
Expand Down Expand Up @@ -124,6 +125,12 @@ The partial results of the `createNodesV2` function. This should be the results

---

### pluginIndex

• **pluginIndex**: `number`

---

### stack

• `Optional` **stack**: `string`
Expand Down
53 changes: 41 additions & 12 deletions packages/nx/src/command-line/import/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
configurePlugins,
runPackageManagerInstallPlugins,
} from '../init/configure-plugins';
import {
checkCompatibleWithPlugins,
updatePluginsInNxJson,
} from '../init/implementation/check-compatible-with-plugins';

const importRemoteName = '__tmp_nx_import__';

Expand Down Expand Up @@ -286,21 +290,30 @@ export async function importHandler(options: ImportOptions) {
packageManager,
destinationGitClient
);

if (installed && plugins.length > 0) {
installed = await runPluginsInstall(plugins, pmc, destinationGitClient);
if (installed) {
const { succeededPlugins } = await configurePlugins(
plugins,
updatePackageScripts,
pmc,
workspaceRoot,
verbose
);
if (succeededPlugins.length > 0) {
if (installed) {
// Check compatibility with existing plugins for the workspace included new imported projects
if (nxJson.plugins?.length > 0) {
const incompatiblePlugins = await checkCompatibleWithPlugins();
if (Object.keys(incompatiblePlugins).length > 0) {
updatePluginsInNxJson(workspaceRoot, incompatiblePlugins);
await destinationGitClient.amendCommit();
}
}
if (plugins.length > 0) {
installed = await runPluginsInstall(plugins, pmc, destinationGitClient);
if (installed) {
const { succeededPlugins } = await configurePlugins(
plugins,
updatePackageScripts,
pmc,
workspaceRoot,
verbose
);
if (succeededPlugins.length > 0) {
await destinationGitClient.amendCommit();
}
}
}
}

console.log(await destinationGitClient.showStat());
Expand All @@ -313,6 +326,22 @@ export async function importHandler(options: ImportOptions) {
`You may need to run "${pmc.install}" manually to resolve the issue. The error is logged above.`,
],
});
if (plugins.length > 0) {
output.error({
title: `Failed to install plugins`,
bodyLines: [
'The following plugins were not installed:',
...plugins.map((p) => `- ${chalk.bold(p)}`),
],
});
output.error({
title: `To install the plugins manually`,
bodyLines: [
'You may need to run commands to install the plugins:',
...plugins.map((p) => `- ${chalk.bold(pmc.exec + ' nx add ' + p)}`),
],
});
}
}

if (source != destination) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {
AggregateCreateNodesError,
CreateMetadataError,
MergeNodesError,
ProjectGraphError,
ProjectsWithNoNameError,
} from '../../../project-graph/error-types';
import { checkCompatibleWithPlugins } from './check-compatible-with-plugins';
import { createProjectGraphAsync } from '../../../project-graph/project-graph';

jest.mock('../../../project-graph/project-graph', () => ({
createProjectGraphAsync: jest.fn(),
}));

describe('checkCompatibleWithPlugins', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return empty object if no errors are thrown', async () => {
(createProjectGraphAsync as any).mockReturnValueOnce(Promise.resolve({}));
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({});
});

it('should return empty object if error is not ProjectConfigurationsError', async () => {
(createProjectGraphAsync as any).mockReturnValueOnce(
Promise.reject(new Error('random error'))
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({});
});

it('should return empty object if error is ProjectsWithNoNameError', async () => {
(createProjectGraphAsync as any).mockReturnValueOnce(
Promise.reject(
new ProjectGraphError(
[
new ProjectsWithNoNameError([], {
project1: { root: 'root1' },
}),
],
undefined,
undefined
)
)
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({});
});

it('should return incompatible plugin with excluded files if error is AggregateCreateNodesError', async () => {
const error = new AggregateCreateNodesError(
[
['file1', undefined],
['file2', undefined],
],
[]
);
error.pluginIndex = 0;
(createProjectGraphAsync as any).mockReturnValueOnce(
Promise.reject(new ProjectGraphError([error], undefined, undefined))
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({
0: [
{ file: 'file1', error: undefined },
{ file: 'file2', error: undefined },
],
});
});

it('should return true if error is MergeNodesError', async () => {
let error = new MergeNodesError({
file: 'file2',
pluginName: 'plugin2',
error: new Error(),
pluginIndex: 1,
});
(createProjectGraphAsync as any).mockReturnValueOnce(
Promise.reject(new ProjectGraphError([error], undefined, undefined))
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({ 1: [{ error, file: 'file2' }] });
});

it('should handle multiple errors', async () => {
const mergeNodesError = new MergeNodesError({
file: 'file2',
pluginName: 'plugin2',
error: new Error(),
pluginIndex: 2,
});
const aggregateError0 = new AggregateCreateNodesError(
[
['file1', undefined],
['file2', undefined],
],
[]
);
aggregateError0.pluginIndex = 0;
const aggregateError2 = new AggregateCreateNodesError(
[
['file3', undefined],
['file4', undefined],
],
[]
);
aggregateError2.pluginIndex = 2;
(createProjectGraphAsync as any).mockReturnValueOnce(
Promise.reject(
new ProjectGraphError(
[
new ProjectsWithNoNameError([], {
project1: { root: 'root1' },
}),
new CreateMetadataError(new Error(), 'file1'),
new AggregateCreateNodesError([], []),
aggregateError0,
mergeNodesError,
aggregateError2,
],
undefined,
undefined
)
)
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({
0: [{ file: 'file1' }, { file: 'file2' }],
2: [
{ file: 'file2', error: mergeNodesError },
{ file: 'file3' },
{ file: 'file4' },
],
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { bold } from 'chalk';

import { NxJsonConfiguration } from '../../../config/nx-json';
import {
isAggregateCreateNodesError,
isMergeNodesError,
isProjectsWithNoNameError,
ProjectGraphError,
} from '../../../project-graph/error-types';
import { workspaceRoot } from '../../../utils/workspace-root';
import { readJsonFile, writeJsonFile } from '../../../utils/fileutils';
import { output } from '../../../utils/output';
import { createProjectGraphAsync } from '../../../project-graph/project-graph';

export interface IncompatibleFiles {
[pluginIndex: number]: { file: string; error?: any }[];
}

/**
* This function checks if the imported project is compatible with the plugins.
* @returns a map of plugin names to files that are incompatible with the plugins
*/
export async function checkCompatibleWithPlugins(): Promise<IncompatibleFiles> {
let pluginToExcludeFiles: IncompatibleFiles = {};
try {
await createProjectGraphAsync();
} catch (projectGraphError) {
if (projectGraphError instanceof ProjectGraphError) {
projectGraphError.getErrors()?.forEach((error) => {
const { pluginIndex, excludeFiles } =
findPluginAndFilesWithError(error) ?? {};
if (pluginIndex !== undefined && excludeFiles?.length) {
pluginToExcludeFiles[pluginIndex] ??= [];
pluginToExcludeFiles[pluginIndex].push(...excludeFiles);
} else if (!isProjectsWithNoNameError(error)) {
// print error if it is not ProjectsWithNoNameError and unable to exclude files
output.error({
title: error.message,
bodyLines: error.stack?.split('\n'),
});
}
});
} else {
output.error({
title:
'Failed to process project graph. Run "nx reset" to fix this. Please report the issue if you keep seeing it.',
bodyLines: projectGraphError.stack?.split('\n'),
});
}
}
return pluginToExcludeFiles;
}

/**
* This function finds the plugin name and files that caused the error.
* @param error the error to find the plugin name and files for
* @returns pluginName and excludeFiles if found, otherwise undefined
*/
function findPluginAndFilesWithError(
error: any
):
| { pluginIndex: number; excludeFiles: { file: string; error?: any }[] }
| undefined {
let pluginIndex: number | undefined;
let excludeFiles: { file: string; error?: any }[] = [];
if (isAggregateCreateNodesError(error)) {
pluginIndex = error.pluginIndex;
excludeFiles =
error.errors?.map((error) => {
return {
file: error?.[0],
error: error?.[1],
};
}) ?? [];
} else if (isMergeNodesError(error)) {
pluginIndex = error.pluginIndex;
excludeFiles = [
{
file: error.file,
error: error,
},
];
}
excludeFiles = excludeFiles.filter(Boolean);
return {
pluginIndex,
excludeFiles,
};
}

/**
* This function updates the plugins in the nx.json file with the given plugin names and files to exclude.
*/
export function updatePluginsInNxJson(
root: string = workspaceRoot,
pluginToExcludeFiles: IncompatibleFiles
): void {
const nxJsonPath = join(root, 'nx.json');
if (!existsSync(nxJsonPath)) {
return;
}
let nxJson: NxJsonConfiguration;
try {
nxJson = readJsonFile<NxJsonConfiguration>(nxJsonPath);
} catch {
// If there is an error reading the nx.json file, no need to update it
return;
}
if (!Object.keys(pluginToExcludeFiles)?.length || !nxJson?.plugins?.length) {
return;
}
Object.entries(pluginToExcludeFiles).forEach(
([pluginIndex, excludeFiles]) => {
let plugin = nxJson.plugins[pluginIndex];
if (!plugin || excludeFiles.length === 0) {
return;
}
if (typeof plugin === 'string') {
plugin = { plugin };
}
output.warn({
title: `The following files were incompatible with ${plugin.plugin} and has been excluded for now:`,
bodyLines: excludeFiles
.map((file: { file: string; error?: any }) => {
const output = [` - ${bold(file.file)}`];
if (file.error?.message) {
output.push(` ${file.error.message}`);
}
return output;
})
.flat(),
});

const excludes = new Set(plugin.exclude ?? []);
excludeFiles.forEach((file) => {
excludes.add(file.file);
});
plugin.exclude = Array.from(excludes);
nxJson.plugins[pluginIndex] = plugin;
}
);
writeJsonFile(nxJsonPath, nxJson);
}
Loading
Loading