From 8efe26c99a4a240b7e312eb132affc7f5fc19b6c Mon Sep 17 00:00:00 2001 From: MaxKless <34165455+MaxKless@users.noreply.github.com> Date: Mon, 24 Jun 2024 09:19:00 +0200 Subject: [PATCH 01/10] fix(core): repair sourcemaps that had file & plugin swapped (#26628) --- .../utils/project-configuration-utils.spec.ts | 54 +++++++++++++++++++ .../utils/project-configuration-utils.ts | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts index 82a70b656fad52..2807e854a3a907 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts @@ -1772,6 +1772,60 @@ describe('project-configuration-utils', () => { `); } }); + + it('should correctly set source maps', async () => { + const { sourceMaps } = await createProjectConfigurations( + undefined, + {}, + ['libs/a/project.json'], + [ + new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), + new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), + ] + ); + expect(sourceMaps).toMatchInlineSnapshot(` + { + "libs/a": { + "name": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "root": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "tags": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "tags.fake-lib": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "targets": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build.executor": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build.options": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build.options.command": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + }, + } + `); + }); }); }); diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.ts index 8fe55f76c56613..6af12c571d0105 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.ts @@ -459,7 +459,7 @@ function mergeCreateNodesResults( > = {}; for (const result of results.flat()) { - const [file, pluginName, nodes] = result; + const [pluginName, file, nodes] = result; const { projects: projectNodes, externalNodes: pluginExternalNodes } = nodes; From 65e0cb90debb3f1e1c6fb09e6b8cd710515baaaf Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 24 Jun 2024 14:25:30 +0100 Subject: [PATCH 02/10] fix(storybook): output should match CLI flag (#26652) ## Current Behavior Output option is not renamed to match CLI flag ## Expected Behavior Output option shuold be renamed to match CLI flag ## Related Issue(s) Fixes # --- .../convert-to-inferred.spec.ts | 12 +- .../lib/build-post-target-transformer.spec.ts | 144 ++++++++++-------- .../lib/build-post-target-transformer.ts | 2 +- 3 files changed, 91 insertions(+), 67 deletions(-) diff --git a/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.spec.ts index 736cda4d6a4068..7139d65b88fc88 100644 --- a/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.spec.ts +++ b/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -235,7 +235,7 @@ describe('Storybook - Convert To Inferred', () => { "output-dir": "../../dist/storybook/apps/app1", }, "outputs": [ - "{projectRoot}/{options.outputDir}", + "{projectRoot}/{options.output-dir}", "{workspaceRoot}/{projectRoot}/storybook-static", "{options.output-dir}", "{options.outputDir}", @@ -311,7 +311,7 @@ describe('Storybook - Convert To Inferred', () => { "output-dir": "../../dist/storybook/apps/app1", }, "outputs": [ - "{projectRoot}/{options.outputDir}", + "{projectRoot}/{options.output-dir}", "{workspaceRoot}/{projectRoot}/storybook-static", "{options.output-dir}", "{options.outputDir}", @@ -386,7 +386,7 @@ describe('Storybook - Convert To Inferred', () => { "webpack-stats-json": true, }, "outputs": [ - "{projectRoot}/{options.outputDir}", + "{projectRoot}/{options.output-dir}", "{workspaceRoot}/{projectRoot}/storybook-static", "{options.output-dir}", "{options.outputDir}", @@ -445,7 +445,7 @@ describe('Storybook - Convert To Inferred', () => { "output-dir": "../../dist/storybook/apps/app1", }, "outputs": [ - "{projectRoot}/{options.outputDir}", + "{projectRoot}/{options.output-dir}", "{workspaceRoot}/{projectRoot}/storybook-static", "{options.output-dir}", "{options.outputDir}", @@ -564,7 +564,7 @@ describe('Storybook - Convert To Inferred', () => { "output-dir": "../../dist/storybook/apps/app1", }, "outputs": [ - "{projectRoot}/{options.outputDir}", + "{projectRoot}/{options.output-dir}", "{workspaceRoot}/{projectRoot}/storybook-static", "{options.output-dir}", "{options.outputDir}", @@ -596,7 +596,7 @@ describe('Storybook - Convert To Inferred', () => { "output-dir": "../../dist/storybook/apps/project2", }, "outputs": [ - "{projectRoot}/{options.outputDir}", + "{projectRoot}/{options.output-dir}", "{workspaceRoot}/{projectRoot}/storybook-static", "{options.output-dir}", "{options.outputDir}", diff --git a/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.spec.ts b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.spec.ts index 86d1e3eb547f52..20e72a84085751 100644 --- a/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.spec.ts +++ b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.spec.ts @@ -70,13 +70,17 @@ describe('buildPostTargetTransformer', () => { export default config;" `); expect(target).toMatchInlineSnapshot(` - { - "options": { - "config-dir": ".storybook", - "output-dir": "../../dist/storybook/myapp", - }, - } - `); + { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + "outputs": [ + "{projectRoot}/{options.output-dir}", + "{projectRoot}/{options.outputDir}", + ], + } + `); }); it('should handle configurations correctly and migrate docsMode and staticDir to storybook config correctly', () => { @@ -157,19 +161,23 @@ describe('buildPostTargetTransformer', () => { export default config;" `); expect(target).toMatchInlineSnapshot(` - { - "configurations": { - "dev": { - "config-dir": "./dev/.storybook", - "output-dir": "../../dist/storybook/myapp/dev", - }, - }, - "options": { - "config-dir": ".storybook", - "output-dir": "../../dist/storybook/myapp", - }, - } - `); + { + "configurations": { + "dev": { + "config-dir": "./dev/.storybook", + "output-dir": "../../dist/storybook/myapp/dev", + }, + }, + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + "outputs": [ + "{projectRoot}/{options.output-dir}", + "{projectRoot}/{options.outputDir}", + ], + } + `); const devConfigFile = tree.read( 'apps/myapp/dev/.storybook/main.ts', 'utf-8' @@ -262,13 +270,17 @@ describe('buildPostTargetTransformer', () => { export default config;" `); expect(target).toMatchInlineSnapshot(` - { - "options": { - "config-dir": ".storybook", - "output-dir": "../../dist/storybook/myapp", - }, - } - `); + { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + "outputs": [ + "{projectRoot}/{options.output-dir}", + "{projectRoot}/{options.outputDir}", + ], + } + `); }); it('should handle configurations correctly and migrate docsMode and staticDir to storybook config correctly', () => { @@ -349,19 +361,23 @@ describe('buildPostTargetTransformer', () => { export default config;" `); expect(target).toMatchInlineSnapshot(` - { - "configurations": { - "dev": { - "config-dir": "./dev/.storybook", - "output-dir": "../../dist/storybook/myapp/dev", - }, - }, - "options": { - "config-dir": ".storybook", - "output-dir": "../../dist/storybook/myapp", - }, - } - `); + { + "configurations": { + "dev": { + "config-dir": "./dev/.storybook", + "output-dir": "../../dist/storybook/myapp/dev", + }, + }, + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + "outputs": [ + "{projectRoot}/{options.output-dir}", + "{projectRoot}/{options.outputDir}", + ], + } + `); const devConfigFile = tree.read( 'apps/myapp/dev/.storybook/main.ts', 'utf-8' @@ -454,13 +470,17 @@ describe('buildPostTargetTransformer', () => { export default config;" `); expect(target).toMatchInlineSnapshot(` - { - "options": { - "config-dir": ".storybook", - "output-dir": "../../dist/storybook/myapp", - }, - } - `); + { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + "outputs": [ + "{projectRoot}/{options.output-dir}", + "{projectRoot}/{options.outputDir}", + ], + } + `); }); it('should handle configurations correctly and migrate docsMode and staticDir to storybook config correctly', () => { @@ -541,19 +561,23 @@ describe('buildPostTargetTransformer', () => { export default config;" `); expect(target).toMatchInlineSnapshot(` - { - "configurations": { - "dev": { - "config-dir": "./dev/.storybook", - "output-dir": "../../dist/storybook/myapp/dev", - }, - }, - "options": { - "config-dir": ".storybook", - "output-dir": "../../dist/storybook/myapp", - }, - } - `); + { + "configurations": { + "dev": { + "config-dir": "./dev/.storybook", + "output-dir": "../../dist/storybook/myapp/dev", + }, + }, + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + "outputs": [ + "{projectRoot}/{options.output-dir}", + "{projectRoot}/{options.outputDir}", + ], + } + `); const devConfigFile = tree.read( 'apps/myapp/dev/.storybook/main.ts', 'utf-8' diff --git a/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts index 4802d4be14ba95..05e6e905f4d220 100644 --- a/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts +++ b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts @@ -107,7 +107,7 @@ export function buildPostTargetTransformer(migrationLogs: AggregatedLog) { if (target.outputs) { processTargetOutputs( target, - [{ newName: 'outputDir', oldName: 'outputDir' }], + [{ newName: 'output-dir', oldName: 'outputDir' }], inferredTargetConfiguration, { projectName: projectDetails.projectName, From dece9afc0d1c8cbe7d24eabe16d935e4d6012bce Mon Sep 17 00:00:00 2001 From: MaxKless <34165455+MaxKless@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:22:15 +0200 Subject: [PATCH 03/10] feat(core): add lifecycle to record task history & retrieve via daemon (#26593) ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes # --- packages/nx/src/daemon/client/client.ts | 24 ++++ .../src/daemon/message-types/task-history.ts | 38 ++++++ .../daemon/server/handle-get-task-history.ts | 9 ++ .../handle-write-task-runs-to-history.ts | 9 ++ packages/nx/src/daemon/server/server.ts | 14 +++ .../src/tasks-runner/default-tasks-runner.ts | 4 +- packages/nx/src/tasks-runner/life-cycle.ts | 36 +++--- .../life-cycles/task-history-life-cycle.ts | 71 +++++++++++ packages/nx/src/tasks-runner/run-command.ts | 6 + .../nx/src/tasks-runner/task-orchestrator.ts | 8 +- packages/nx/src/utils/serialize-target.ts | 3 + packages/nx/src/utils/task-history.ts | 114 ++++++++++++++++++ .../workspace/src/utils/cli-config-utils.ts | 3 + 13 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 packages/nx/src/daemon/message-types/task-history.ts create mode 100644 packages/nx/src/daemon/server/handle-get-task-history.ts create mode 100644 packages/nx/src/daemon/server/handle-write-task-runs-to-history.ts create mode 100644 packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts create mode 100644 packages/nx/src/utils/serialize-target.ts create mode 100644 packages/nx/src/utils/task-history.ts diff --git a/packages/nx/src/daemon/client/client.ts b/packages/nx/src/daemon/client/client.ts index 0c5c68516124d0..baaf30605dffa6 100644 --- a/packages/nx/src/daemon/client/client.ts +++ b/packages/nx/src/daemon/client/client.ts @@ -45,6 +45,11 @@ import { } from '../message-types/get-files-in-directory'; import { HASH_GLOB, HandleHashGlobMessage } from '../message-types/hash-glob'; import { NxWorkspaceFiles } from '../../native'; +import { TaskRun } from '../../utils/task-history'; +import { + HandleGetTaskHistoryForHashesMessage, + HandleWriteTaskRunsToHistoryMessage, +} from '../message-types/task-history'; const DAEMON_ENV_SETTINGS = { NX_PROJECT_GLOB_CACHE: 'false', @@ -312,6 +317,25 @@ export class DaemonClient { return this.sendToDaemonViaQueue(message); } + getTaskHistoryForHashes(hashes: string[]): Promise<{ + [hash: string]: TaskRun[]; + }> { + const message: HandleGetTaskHistoryForHashesMessage = { + type: 'GET_TASK_HISTORY_FOR_HASHES', + hashes, + }; + + return this.sendToDaemonViaQueue(message); + } + + writeTaskRunsToHistory(taskRuns: TaskRun[]): Promise { + const message: HandleWriteTaskRunsToHistoryMessage = { + type: 'WRITE_TASK_RUNS_TO_HISTORY', + taskRuns, + }; + return this.sendMessageToDaemon(message); + } + async isServerAvailable(): Promise { return new Promise((resolve) => { try { diff --git a/packages/nx/src/daemon/message-types/task-history.ts b/packages/nx/src/daemon/message-types/task-history.ts new file mode 100644 index 00000000000000..d940445a4c2698 --- /dev/null +++ b/packages/nx/src/daemon/message-types/task-history.ts @@ -0,0 +1,38 @@ +import { TaskRun } from '../../utils/task-history'; + +export const GET_TASK_HISTORY_FOR_HASHES = + 'GET_TASK_HISTORY_FOR_HASHES' as const; + +export type HandleGetTaskHistoryForHashesMessage = { + type: typeof GET_TASK_HISTORY_FOR_HASHES; + hashes: string[]; +}; + +export function isHandleGetTaskHistoryForHashesMessage( + message: unknown +): message is HandleGetTaskHistoryForHashesMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + message['type'] === GET_TASK_HISTORY_FOR_HASHES + ); +} + +export const WRITE_TASK_RUNS_TO_HISTORY = 'WRITE_TASK_RUNS_TO_HISTORY' as const; + +export type HandleWriteTaskRunsToHistoryMessage = { + type: typeof WRITE_TASK_RUNS_TO_HISTORY; + taskRuns: TaskRun[]; +}; + +export function isHandleWriteTaskRunsToHistoryMessage( + message: unknown +): message is HandleWriteTaskRunsToHistoryMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + message['type'] === WRITE_TASK_RUNS_TO_HISTORY + ); +} diff --git a/packages/nx/src/daemon/server/handle-get-task-history.ts b/packages/nx/src/daemon/server/handle-get-task-history.ts new file mode 100644 index 00000000000000..66d3903445330a --- /dev/null +++ b/packages/nx/src/daemon/server/handle-get-task-history.ts @@ -0,0 +1,9 @@ +import { getHistoryForHashes } from '../../utils/task-history'; + +export async function handleGetTaskHistoryForHashes(hashes: string[]) { + const history = await getHistoryForHashes(hashes); + return { + response: JSON.stringify(history), + description: 'handleGetTaskHistoryForHashes', + }; +} diff --git a/packages/nx/src/daemon/server/handle-write-task-runs-to-history.ts b/packages/nx/src/daemon/server/handle-write-task-runs-to-history.ts new file mode 100644 index 00000000000000..b585750d84ce78 --- /dev/null +++ b/packages/nx/src/daemon/server/handle-write-task-runs-to-history.ts @@ -0,0 +1,9 @@ +import { TaskRun, writeTaskRunsToHistory } from '../../utils/task-history'; + +export async function handleWriteTaskRunsToHistory(taskRuns: TaskRun[]) { + await writeTaskRunsToHistory(taskRuns); + return { + response: 'true', + description: 'handleWriteTaskRunsToHistory', + }; +} diff --git a/packages/nx/src/daemon/server/server.ts b/packages/nx/src/daemon/server/server.ts index dcd51bc464aa24..33b709511119a6 100644 --- a/packages/nx/src/daemon/server/server.ts +++ b/packages/nx/src/daemon/server/server.ts @@ -70,6 +70,12 @@ import { import { handleGetFilesInDirectory } from './handle-get-files-in-directory'; import { HASH_GLOB, isHandleHashGlobMessage } from '../message-types/hash-glob'; import { handleHashGlob } from './handle-hash-glob'; +import { + isHandleGetTaskHistoryForHashesMessage, + isHandleWriteTaskRunsToHistoryMessage, +} from '../message-types/task-history'; +import { handleGetTaskHistoryForHashes } from './handle-get-task-history'; +import { handleWriteTaskRunsToHistory } from './handle-write-task-runs-to-history'; let performanceObserver: PerformanceObserver | undefined; let workspaceWatcherError: Error | undefined; @@ -202,6 +208,14 @@ async function handleMessage(socket, data: string) { await handleResult(socket, HASH_GLOB, () => handleHashGlob(payload.globs, payload.exclude) ); + } else if (isHandleGetTaskHistoryForHashesMessage(payload)) { + await handleResult(socket, 'GET_TASK_HISTORY_FOR_HASHES', () => + handleGetTaskHistoryForHashes(payload.hashes) + ); + } else if (isHandleWriteTaskRunsToHistoryMessage(payload)) { + await handleResult(socket, 'WRITE_TASK_RUNS_TO_HISTORY', () => + handleWriteTaskRunsToHistory(payload.taskRuns) + ); } else { await respondWithErrorAndExit( socket, diff --git a/packages/nx/src/tasks-runner/default-tasks-runner.ts b/packages/nx/src/tasks-runner/default-tasks-runner.ts index a5cb33bfd7473d..eeb4a8f4183d12 100644 --- a/packages/nx/src/tasks-runner/default-tasks-runner.ts +++ b/packages/nx/src/tasks-runner/default-tasks-runner.ts @@ -56,11 +56,11 @@ export const defaultTasksRunner: TasksRunner< (options as any)['parallel'] = Number((options as any)['maxParallel'] || 3); } - options.lifeCycle.startCommand(); + await options.lifeCycle.startCommand(); try { return await runAllTasks(tasks, options, context); } finally { - options.lifeCycle.endCommand(); + await options.lifeCycle.endCommand(); } }; diff --git a/packages/nx/src/tasks-runner/life-cycle.ts b/packages/nx/src/tasks-runner/life-cycle.ts index 1192a73415c161..2bce065d3e93fd 100644 --- a/packages/nx/src/tasks-runner/life-cycle.ts +++ b/packages/nx/src/tasks-runner/life-cycle.ts @@ -13,11 +13,11 @@ export interface TaskMetadata { } export interface LifeCycle { - startCommand?(): void; + startCommand?(): void | Promise; - endCommand?(): void; + endCommand?(): void | Promise; - scheduleTask?(task: Task): void; + scheduleTask?(task: Task): void | Promise; /** * @deprecated use startTasks @@ -33,9 +33,12 @@ export interface LifeCycle { */ endTask?(task: Task, code: number): void; - startTasks?(task: Task[], metadata: TaskMetadata): void; + startTasks?(task: Task[], metadata: TaskMetadata): void | Promise; - endTasks?(taskResults: TaskResult[], metadata: TaskMetadata): void; + endTasks?( + taskResults: TaskResult[], + metadata: TaskMetadata + ): void | Promise; printTaskTerminalOutput?( task: Task, @@ -47,26 +50,26 @@ export interface LifeCycle { export class CompositeLifeCycle implements LifeCycle { constructor(private readonly lifeCycles: LifeCycle[]) {} - startCommand(): void { + async startCommand(): Promise { for (let l of this.lifeCycles) { if (l.startCommand) { - l.startCommand(); + await l.startCommand(); } } } - endCommand(): void { + async endCommand(): Promise { for (let l of this.lifeCycles) { if (l.endCommand) { - l.endCommand(); + await l.endCommand(); } } } - scheduleTask(task: Task): void { + async scheduleTask(task: Task): Promise { for (let l of this.lifeCycles) { if (l.scheduleTask) { - l.scheduleTask(task); + await l.scheduleTask(task); } } } @@ -87,20 +90,23 @@ export class CompositeLifeCycle implements LifeCycle { } } - startTasks(tasks: Task[], metadata: TaskMetadata): void { + async startTasks(tasks: Task[], metadata: TaskMetadata): Promise { for (let l of this.lifeCycles) { if (l.startTasks) { - l.startTasks(tasks, metadata); + await l.startTasks(tasks, metadata); } else if (l.startTask) { tasks.forEach((t) => l.startTask(t)); } } } - endTasks(taskResults: TaskResult[], metadata: TaskMetadata): void { + async endTasks( + taskResults: TaskResult[], + metadata: TaskMetadata + ): Promise { for (let l of this.lifeCycles) { if (l.endTasks) { - l.endTasks(taskResults, metadata); + await l.endTasks(taskResults, metadata); } else if (l.endTask) { taskResults.forEach((t) => l.endTask(t.task, t.code)); } diff --git a/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts b/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts new file mode 100644 index 00000000000000..6768354b909603 --- /dev/null +++ b/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts @@ -0,0 +1,71 @@ +import { serializeTarget } from '../../utils/serialize-target'; +import { Task } from '../../config/task-graph'; +import { output } from '../../utils/output'; +import { + getHistoryForHashes, + TaskRun, + writeTaskRunsToHistory as writeTaskRunsToHistory, +} from '../../utils/task-history'; +import { LifeCycle, TaskResult } from '../life-cycle'; + +export class TaskHistoryLifeCycle implements LifeCycle { + private startTimings: Record = {}; + private taskRuns: TaskRun[] = []; + + startTasks(tasks: Task[]): void { + for (let task of tasks) { + this.startTimings[task.id] = new Date().getTime(); + } + } + + async endTasks(taskResults: TaskResult[]) { + const taskRuns: TaskRun[] = taskResults.map((taskResult) => ({ + project: taskResult.task.target.project, + target: taskResult.task.target.target, + configuration: taskResult.task.target.configuration, + hash: taskResult.task.hash, + code: taskResult.code.toString(), + status: taskResult.status, + start: ( + taskResult.task.startTime ?? this.startTimings[taskResult.task.id] + ).toString(), + end: (taskResult.task.endTime ?? new Date().getTime()).toString(), + })); + this.taskRuns.push(...taskRuns); + } + + async endCommand() { + await writeTaskRunsToHistory(this.taskRuns); + const history = await getHistoryForHashes(this.taskRuns.map((t) => t.hash)); + const flakyTasks: string[] = []; + + // check if any hash has different exit codes => flaky + for (let hash in history) { + if ( + history[hash].length > 1 && + history[hash].some((run) => run.code !== history[hash][0].code) + ) { + flakyTasks.push( + serializeTarget( + history[hash][0].project, + history[hash][0].target, + history[hash][0].configuration + ) + ); + } + } + if (flakyTasks.length > 0) { + output.warn({ + title: `Nx detected ${ + flakyTasks.length === 1 ? 'a flaky task' : ' flaky tasks' + }`, + bodyLines: [ + , + ...flakyTasks.map((t) => ` ${t}`), + '', + `Flaky tasks can disrupt your CI pipeline. Automatically retry them with Nx Cloud. Learn more at https://nx.dev/ci/features/flaky-tasks`, + ], + }); + } + } +} diff --git a/packages/nx/src/tasks-runner/run-command.ts b/packages/nx/src/tasks-runner/run-command.ts index 3b4e05e14e66c3..06a22716e8ab70 100644 --- a/packages/nx/src/tasks-runner/run-command.ts +++ b/packages/nx/src/tasks-runner/run-command.ts @@ -16,6 +16,7 @@ import { createRunOneDynamicOutputRenderer } from './life-cycles/dynamic-run-one import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph'; import { NxJsonConfiguration, + readNxJson, TargetDefaults, TargetDependencies, } from '../config/nx-json'; @@ -28,6 +29,8 @@ import { hashTasksThatDoNotDependOnOutputsOfOtherTasks } from '../hasher/hash-ta import { daemonClient } from '../daemon/client/client'; import { StoreRunInformationLifeCycle } from './life-cycles/store-run-information-life-cycle'; import { createTaskHasher } from '../hasher/create-task-hasher'; +import { TaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle'; +import { isNxCloudUsed } from '../utils/nx-cloud-utils'; async function getTerminalOutputLifeCycle( initiatingProject: string, @@ -325,6 +328,9 @@ function constructLifeCycles(lifeCycle: LifeCycle) { if (process.env.NX_PROFILE) { lifeCycles.push(new TaskProfilingLifeCycle(process.env.NX_PROFILE)); } + if (!isNxCloudUsed(readNxJson())) { + lifeCycles.push(new TaskHistoryLifeCycle()); + } return lifeCycles; } diff --git a/packages/nx/src/tasks-runner/task-orchestrator.ts b/packages/nx/src/tasks-runner/task-orchestrator.ts index c68feac797665c..fbe7295a015737 100644 --- a/packages/nx/src/tasks-runner/task-orchestrator.ts +++ b/packages/nx/src/tasks-runner/task-orchestrator.ts @@ -159,7 +159,7 @@ export class TaskOrchestrator { ); } - this.options.lifeCycle.scheduleTask(task); + await this.options.lifeCycle.scheduleTask(task); return taskSpecificEnv; } @@ -176,7 +176,7 @@ export class TaskOrchestrator { this.batchEnv ); } - this.options.lifeCycle.scheduleTask(task); + await this.options.lifeCycle.scheduleTask(task); }) ); } @@ -520,7 +520,7 @@ export class TaskOrchestrator { // region Lifecycle private async preRunSteps(tasks: Task[], metadata: TaskMetadata) { - this.options.lifeCycle.startTasks(tasks, metadata); + await this.options.lifeCycle.startTasks(tasks, metadata); } private async postRunSteps( @@ -573,7 +573,7 @@ export class TaskOrchestrator { 'cache-results-end' ); } - this.options.lifeCycle.endTasks( + await this.options.lifeCycle.endTasks( results.map((result) => { const code = result.status === 'success' || diff --git a/packages/nx/src/utils/serialize-target.ts b/packages/nx/src/utils/serialize-target.ts new file mode 100644 index 00000000000000..fdc305f34dcec5 --- /dev/null +++ b/packages/nx/src/utils/serialize-target.ts @@ -0,0 +1,3 @@ +export function serializeTarget(project, target, configuration) { + return [project, target, configuration].filter((part) => !!part).join(':'); +} diff --git a/packages/nx/src/utils/task-history.ts b/packages/nx/src/utils/task-history.ts new file mode 100644 index 00000000000000..e290944dcf7c22 --- /dev/null +++ b/packages/nx/src/utils/task-history.ts @@ -0,0 +1,114 @@ +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { daemonClient } from '../daemon/client/client'; +import { isOnDaemon } from '../daemon/is-on-daemon'; +import { workspaceDataDirectory } from './cache-directory'; + +const taskRunKeys = [ + 'project', + 'target', + 'configuration', + 'hash', + 'code', + 'status', + 'start', + 'end', +] as const; + +export type TaskRun = Record<(typeof taskRunKeys)[number], string>; + +let taskHistory: TaskRun[] | undefined = undefined; +let taskHashToIndicesMap: Map = new Map(); + +export async function getHistoryForHashes(hashes: string[]): Promise<{ + [hash: string]: TaskRun[]; +}> { + if (isOnDaemon() || !daemonClient.enabled()) { + if (taskHistory === undefined) { + loadTaskHistoryFromDisk(); + } + + const result: { [hash: string]: TaskRun[] } = {}; + for (let hash of hashes) { + const indices = taskHashToIndicesMap.get(hash); + if (!indices) { + result[hash] = []; + } else { + result[hash] = indices.map((index) => taskHistory[index]); + } + } + + return result; + } + + return await daemonClient.getTaskHistoryForHashes(hashes); +} + +export async function writeTaskRunsToHistory( + taskRuns: TaskRun[] +): Promise { + if (isOnDaemon() || !daemonClient.enabled()) { + if (taskHistory === undefined) { + loadTaskHistoryFromDisk(); + } + + const serializedLines: string[] = []; + for (let taskRun of taskRuns) { + const serializedLine = taskRunKeys.map((key) => taskRun[key]).join(','); + serializedLines.push(serializedLine); + recordTaskRunInMemory(taskRun); + } + + if (!existsSync(taskHistoryFile)) { + writeFileSync(taskHistoryFile, `${taskRunKeys.join(',')}\n`); + } + appendFileSync(taskHistoryFile, serializedLines.join('\n') + '\n'); + } else { + await daemonClient.writeTaskRunsToHistory(taskRuns); + } +} + +export const taskHistoryFile = join(workspaceDataDirectory, 'task-history.csv'); + +function loadTaskHistoryFromDisk() { + taskHashToIndicesMap.clear(); + taskHistory = []; + + if (!existsSync(taskHistoryFile)) { + return; + } + + const fileContent = readFileSync(taskHistoryFile, 'utf8'); + if (!fileContent) { + return; + } + const lines = fileContent.split('\n'); + + // if there are no lines or just the header, return + if (lines.length <= 1) { + return; + } + + const contentLines = lines.slice(1).filter((l) => l.trim() !== ''); + + // read the values from csv format where each header is a key and the value is the value + for (let line of contentLines) { + const values = line.trim().split(','); + + const run: Partial = {}; + taskRunKeys.forEach((header, index) => { + run[header] = values[index]; + }); + + recordTaskRunInMemory(run as TaskRun); + } +} + +function recordTaskRunInMemory(taskRun: TaskRun) { + const index = taskHistory.push(taskRun) - 1; + if (taskHashToIndicesMap.has(taskRun.hash)) { + taskHashToIndicesMap.get(taskRun.hash).push(index); + } else { + taskHashToIndicesMap.set(taskRun.hash, [index]); + } +} diff --git a/packages/workspace/src/utils/cli-config-utils.ts b/packages/workspace/src/utils/cli-config-utils.ts index 1548dba7cbd8c0..507d26ef5f8d7f 100644 --- a/packages/workspace/src/utils/cli-config-utils.ts +++ b/packages/workspace/src/utils/cli-config-utils.ts @@ -22,6 +22,9 @@ export function editTarget(targetString: string, callback) { return serializeTarget(callback(parsedTarget)); } +/** + * @deprecated use the utility from nx/src/utils instead + */ export function serializeTarget({ project, target, config }) { return [project, target, config].filter((part) => !!part).join(':'); } From 8e4db827099ee25f3b254f826189ab188407957b Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Mon, 24 Jun 2024 13:50:18 -0400 Subject: [PATCH 04/10] fix(bundling): rename aliases for @nx/rollup:convert-to-inferred generator (#26659) If you use `entryFile`, `f`, or `exports` in `project.json`, these fields do not migrate correctly to `rollup.config.js`. ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes # --- .../convert-to-inferred.spec.ts | 53 ++++++++++++++++- ...act-rollup-config-from-executor-options.ts | 12 +++- .../src/plugins/with-nx/with-nx-options.ts | 58 ++++++++++++++++++- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/rollup/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/rollup/src/generators/convert-to-inferred/convert-to-inferred.spec.ts index fbaf582b9a0be6..8c875cf4bf053a 100644 --- a/packages/rollup/src/generators/convert-to-inferred/convert-to-inferred.spec.ts +++ b/packages/rollup/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -40,7 +40,7 @@ function createProject(tree: Tree, opts: Partial = {}) { projectOpts.targetOptions.outputPath ??= `dist/${projectOpts.root}`; projectOpts.targetOptions.tsConfig ??= `${projectOpts.root}/tsconfig.lib.json`; projectOpts.targetOptions.compiler ??= 'babel'; - projectOpts.targetOptions.format ??= ['esm']; + projectOpts.targetOptions.format ??= projectOpts.targetOptions.f ?? ['esm']; projectOpts.targetOptions.external ??= []; projectOpts.targetOptions.assets ??= []; } @@ -516,6 +516,57 @@ describe('Rollup - Convert Executors To Plugin', () => { " `); }); + + it('should rename aliases to the original option name', async () => { + const project = createProject(tree, { + name: 'mypkg', + root: 'mypkg', + targetOptions: { + entryFile: 'mypkg/src/foo.ts', + f: ['cjs'], + exports: true, + }, + }); + + await convertToInferred(tree, { project: project.name }); + + expect(readNxJson(tree).plugins).toEqual([ + { + options: { + targetName: 'build', + }, + plugin: '@nx/rollup/plugin', + }, + ]); + expect(tree.read('mypkg/rollup.config.js', 'utf-8')) + .toMatchInlineSnapshot(` + "const { withNx } = require('@nx/rollup/with-nx'); + + // These options were migrated by @nx/rollup:convert-to-inferred from project.json + const options = { + main: './src/index.ts', + format: ['cjs'], + generateExportsField: true, + outputPath: '../dist/mypkg', + tsConfig: './tsconfig.lib.json', + compiler: 'babel', + external: [], + assets: [], + }; + + const config = withNx(options, { + // Provide additional rollup configuration here. See: https://rollupjs.org/configuration-options + // e.g. + // output: { sourcemap: true }, + }); + + module.exports = config; + " + `); + expect(tree.exists('otherpkg1/rollup.config.js')).toBe(false); + expect(tree.exists('otherpkg2/rollup.config.js')).toBe(false); + expect(readProjectConfiguration(tree, project.name).targets).toEqual({}); + }); }); describe('all projects', () => { diff --git a/packages/rollup/src/generators/convert-to-inferred/lib/extract-rollup-config-from-executor-options.ts b/packages/rollup/src/generators/convert-to-inferred/lib/extract-rollup-config-from-executor-options.ts index 5eb1bbbd46d419..df9694f4630d89 100644 --- a/packages/rollup/src/generators/convert-to-inferred/lib/extract-rollup-config-from-executor-options.ts +++ b/packages/rollup/src/generators/convert-to-inferred/lib/extract-rollup-config-from-executor-options.ts @@ -2,6 +2,12 @@ import { joinPathFragments, stripIndents, Tree } from '@nx/devkit'; import { RollupExecutorOptions } from '../../../executors/rollup/schema'; import { normalizePathOptions } from './normalize-path-options'; +const aliases = { + entryFile: 'main', + exports: 'generateExportsField', + f: 'format', +}; + export function extractRollupConfigFromExecutorOptions( tree: Tree, options: RollupExecutorOptions, @@ -36,7 +42,11 @@ export function extractRollupConfigFromExecutorOptions( for (const [key, value] of Object.entries(options)) { if (key === 'watch') continue; delete options[key]; - defaultOptions[key] = value; + if (aliases[key]) { + defaultOptions[aliases[key]] = value; + } else { + defaultOptions[key] = value; + } } let configurationOptions: Record>; if (hasConfigurations) { diff --git a/packages/rollup/src/plugins/with-nx/with-nx-options.ts b/packages/rollup/src/plugins/with-nx/with-nx-options.ts index 520b2bdc9d19bf..9558d569ef5219 100644 --- a/packages/rollup/src/plugins/with-nx/with-nx-options.ts +++ b/packages/rollup/src/plugins/with-nx/with-nx-options.ts @@ -1,24 +1,76 @@ -// TODO: Add TSDoc export interface RollupWithNxPluginOptions { + /** + * Additional entry-points to add to exports field in the package.json file. + * */ additionalEntryPoints?: string[]; + /** + * Allow JavaScript files to be compiled. + */ allowJs?: boolean; + /** + * List of static assets. + */ assets?: any[]; + /** + * Whether to set rootmode to upward. See https://babeljs.io/docs/en/options#rootmode + */ babelUpwardRootMode?: boolean; + /** + * Which compiler to use. + */ compiler?: 'babel' | 'tsc' | 'swc'; + /** + * Delete the output path before building. Defaults to true. + */ deleteOutputPath?: boolean; + /** + * A list of external modules that will not be bundled (`react`, `react-dom`, etc.). Can also be set to `all` (bundle nothing) or `none` (bundle everything). + */ external?: string[] | 'all' | 'none'; + /** + * CSS files will be extracted to the output folder. Alternatively custom filename can be provided (e.g. styles.css) + */ extractCss?: boolean | string; + /** + * List of module formats to output. Defaults to matching format from tsconfig (e.g. CJS for CommonJS, and ESM otherwise). + */ format?: ('cjs' | 'esm')[]; + /** + * Update the output package.json file's 'exports' field. This field is used by Node and bundles. + */ generateExportsField?: boolean; + /** + * Sets `javascriptEnabled` option for less loader + */ javascriptEnabled?: boolean; + /** + * The path to the entry file, relative to project. + */ main: string; - /** @deprecated Do not set this. The package.json file in project root is detected automatically. */ + /** + * The path to package.json file. + * @deprecated Do not set this. The package.json file in project root is detected automatically. + */ project?: string; + /** + * Name of the main output file. Defaults same basename as 'main' file. + */ outputFileName?: string; + /** + * The output path of the generated files. + */ outputPath: string; - rollupConfig?: string | string[]; + /** + * Whether to skip TypeScript type checking. + */ skipTypeCheck?: boolean; + /** + * Prevents 'type' field from being added to compiled package.json file. Use this if you are having an issue with this field. + */ skipTypeField?: boolean; + /** + * The path to tsconfig file. + */ tsConfig: string; } From cb4cff7251855a8bc7b481636ab1e56c2bbe20ac Mon Sep 17 00:00:00 2001 From: Jason Jean Date: Mon, 24 Jun 2024 11:06:28 -0700 Subject: [PATCH 05/10] fix(core): pick up changes to plugins configuration in daemon (#26625) ## Current Behavior The previous set of plugins are reused even when the plugins configuration change (plugins are added/removed or have their options changed). ## Expected Behavior Plugins are cleaned up and reloaded when the plugins configuration change. ## Related Issue(s) Fixes # --- packages/nx/src/daemon/server/plugins.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/nx/src/daemon/server/plugins.ts b/packages/nx/src/daemon/server/plugins.ts index f886796ea843ce..3e6da219c98a8a 100644 --- a/packages/nx/src/daemon/server/plugins.ts +++ b/packages/nx/src/daemon/server/plugins.ts @@ -1,3 +1,4 @@ +import { hashObject } from '../../hasher/file-hasher'; import { readNxJson } from '../../config/nx-json'; import { LoadedNxPlugin, @@ -5,14 +6,28 @@ import { } from '../../project-graph/plugins/internal-api'; import { workspaceRoot } from '../../utils/workspace-root'; +let currentPluginsConfigurationHash: string; let loadedPlugins: LoadedNxPlugin[]; let cleanup: () => void; export async function getPlugins() { - if (loadedPlugins) { + const pluginsConfiguration = readNxJson().plugins ?? []; + const pluginsConfigurationHash = hashObject(pluginsConfiguration); + + // If the plugins configuration has not changed, reuse the current plugins + if ( + loadedPlugins && + pluginsConfigurationHash === currentPluginsConfigurationHash + ) { return loadedPlugins; } - const pluginsConfiguration = readNxJson().plugins ?? []; + + // Cleanup current plugins before loading new ones + if (cleanup) { + cleanup(); + } + + currentPluginsConfigurationHash = pluginsConfigurationHash; const [result, cleanupFn] = await loadNxPlugins( pluginsConfiguration, workspaceRoot From 3b2c42a8a507e3d87ed0db0cb20f340c26da6337 Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Mon, 24 Jun 2024 15:22:31 -0400 Subject: [PATCH 06/10] fix(core): isolated plugins should provide cleanup function (#26657) ## Current Behavior - Isolated plugins return no-op cleanup function. ## Expected Behavior - Isolated plugins cleanup fn shuts them down. ## Related Issue(s) In #26625 we are starting to reload plugins when their configurations change. This makes sense, but means we should actually shutdown + reload workers. Fixes # --- .../project-graph/plugins/isolation/index.ts | 22 ++++++++++++++----- .../plugins/isolation/plugin-pool.ts | 22 ++++++++++++------- .../nx/src/project-graph/project-graph.ts | 8 ++++++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/nx/src/project-graph/plugins/isolation/index.ts b/packages/nx/src/project-graph/plugins/isolation/index.ts index 5e422cb64b7636..19a5ba5abac9fa 100644 --- a/packages/nx/src/project-graph/plugins/isolation/index.ts +++ b/packages/nx/src/project-graph/plugins/isolation/index.ts @@ -6,20 +6,30 @@ import { loadRemoteNxPlugin } from './plugin-pool'; /** * Used to ensure 1 plugin : 1 worker */ -const remotePluginCache = new Map>(); +const remotePluginCache = new Map< + string, + readonly [Promise, () => void] +>(); export function loadNxPluginInIsolation( plugin: PluginConfiguration, root = workspaceRoot -): [Promise, () => void] { +): readonly [Promise, () => void] { const cacheKey = JSON.stringify(plugin); if (remotePluginCache.has(cacheKey)) { - return [remotePluginCache.get(cacheKey), () => {}]; + return remotePluginCache.get(cacheKey); } - const loadingPlugin = loadRemoteNxPlugin(plugin, root); - remotePluginCache.set(cacheKey, loadingPlugin); + const [loadingPlugin, cleanup] = loadRemoteNxPlugin(plugin, root); // We clean up plugin workers when Nx process completes. - return [loadingPlugin, () => {}]; + const val = [ + loadingPlugin, + () => { + cleanup(); + remotePluginCache.delete(cacheKey); + }, + ] as const; + remotePluginCache.set(cacheKey, val); + return val; } diff --git a/packages/nx/src/project-graph/plugins/isolation/plugin-pool.ts b/packages/nx/src/project-graph/plugins/isolation/plugin-pool.ts index a34c8f6dc6ae9f..ecfc269de746e4 100644 --- a/packages/nx/src/project-graph/plugins/isolation/plugin-pool.ts +++ b/packages/nx/src/project-graph/plugins/isolation/plugin-pool.ts @@ -22,7 +22,7 @@ interface PendingPromise { export function loadRemoteNxPlugin( plugin: PluginConfiguration, root: string -): Promise { +): [Promise, () => void] { // this should only really be true when running unit tests within // the Nx repo. We still need to start the worker in this case, // but its typescript. @@ -66,13 +66,19 @@ export function loadRemoteNxPlugin( cleanupFunctions.add(cleanupFunction); - return new Promise((res, rej) => { - worker.on( - 'message', - createWorkerHandler(worker, pendingPromises, res, rej) - ); - worker.on('exit', exitHandler); - }); + return [ + new Promise((res, rej) => { + worker.on( + 'message', + createWorkerHandler(worker, pendingPromises, res, rej) + ); + worker.on('exit', exitHandler); + }), + () => { + cleanupFunction(); + cleanupFunctions.delete(cleanupFunction); + }, + ]; } function shutdownPluginWorker(worker: ChildProcess) { diff --git a/packages/nx/src/project-graph/project-graph.ts b/packages/nx/src/project-graph/project-graph.ts index 05e2086d5a7add..81a5d51b7e6d5b 100644 --- a/packages/nx/src/project-graph/project-graph.ts +++ b/packages/nx/src/project-graph/project-graph.ts @@ -148,7 +148,13 @@ export async function buildProjectGraphAndSourceMapsWithoutDaemon() { throw e; } } finally { - cleanup(); + // When plugins are isolated we don't clean them up during + // a single run of the CLI. They are cleaned up when the CLI + // process exits. Cleaning them here could cause issues if pending + // promises are not resolved. + if (process.env.NX_ISOLATE_PLUGINS !== 'true') { + cleanup(); + } } const { projectGraph, projectFileMapCache } = projectGraphResult; From 0e7e4690f21d24496dea1286cb92ac0ea3e28e29 Mon Sep 17 00:00:00 2001 From: Emily Xiong Date: Mon, 24 Jun 2024 13:05:32 -0700 Subject: [PATCH 07/10] feat(gradle): support composite build (#25990) ## Current Behavior ## Expected Behavior ![Workspace Project Graph (2)](https://github.com/nrwl/nx/assets/16211801/9776a642-16c6-45ee-a253-cfc4b86a12a8) ## Related Issue(s) Fixes # --- e2e/gradle/gradle/wrapper/gradle-wrapper.jar | Bin 43462 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 +- e2e/gradle/src/gradle.test.ts | 4 +- packages/gradle/migrations.json | 9 +- packages/gradle/migrations.spec.ts | 8 ++ packages/gradle/src/generators/init/init.ts | 77 ++++++++--- .../19-4-0/add-project-report-all.spec.ts | 28 ++++ .../19-4-0/add-project-report-all.ts | 9 ++ .../gradle/src/plugin/dependencies.spec.ts | 72 +++++++++++ packages/gradle/src/plugin/dependencies.ts | 65 +++++----- .../gradle-composite-dependencies.txt | 60 +++++++++ .../utils/__mocks__/gradle-dependencies.txt | 121 ++++++++++++++++++ .../src/utils/get-gradle-report.spec.ts | 2 +- .../gradle/src/utils/get-gradle-report.ts | 47 +++++-- .../implementation/dot-nx/add-nx-scripts.ts | 11 +- 15 files changed, 449 insertions(+), 69 deletions(-) create mode 100644 packages/gradle/migrations.spec.ts create mode 100644 packages/gradle/src/migrations/19-4-0/add-project-report-all.spec.ts create mode 100644 packages/gradle/src/migrations/19-4-0/add-project-report-all.ts create mode 100644 packages/gradle/src/plugin/dependencies.spec.ts create mode 100644 packages/gradle/src/utils/__mocks__/gradle-composite-dependencies.txt create mode 100644 packages/gradle/src/utils/__mocks__/gradle-dependencies.txt diff --git a/e2e/gradle/gradle/wrapper/gradle-wrapper.jar b/e2e/gradle/gradle/wrapper/gradle-wrapper.jar index d64cd4917707c1f8861d8cb53dd15194d4248596..e6441136f3d4ba8a0da8d277868979cfbc8ad796 100644 GIT binary patch delta 34118 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 delta 34176 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 SGW6ptSs!X7{rG*o_g?|vpSEZK diff --git a/e2e/gradle/gradle/wrapper/gradle-wrapper.properties b/e2e/gradle/gradle/wrapper/gradle-wrapper.properties index 1af9e0930b89b3..a0777a32ceeb7c 100644 --- a/e2e/gradle/gradle/wrapper/gradle-wrapper.properties +++ b/e2e/gradle/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip -networkTimeout=10000 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +validateDistributionUrl=false zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/e2e/gradle/src/gradle.test.ts b/e2e/gradle/src/gradle.test.ts index a40fbf4fcbb747..76258731078137 100644 --- a/e2e/gradle/src/gradle.test.ts +++ b/e2e/gradle/src/gradle.test.ts @@ -53,7 +53,7 @@ describe('Gradle', () => { createFile( `app2/build.gradle`, `plugins { - id 'gradleProject.groovy-application-conventions' + id 'buildlogic.groovy-application-conventions' } dependencies { @@ -64,7 +64,7 @@ dependencies { createFile( `app2/build.gradle.kts`, `plugins { - id("gradleProject.kotlin-library-conventions") + id("buildlogic.kotlin-application-conventions") } dependencies { diff --git a/packages/gradle/migrations.json b/packages/gradle/migrations.json index f9d34cef28c2cd..5439fbbaab6ea6 100644 --- a/packages/gradle/migrations.json +++ b/packages/gradle/migrations.json @@ -1,4 +1,11 @@ { - "generators": {}, + "generators": { + "add-project-report-all": { + "version": "19.4.0-beta.1", + "cli": "nx", + "description": "Add task projectReportAll to build.gradle file", + "factory": "./src/migrations/19-4-0/add-project-report-all" + } + }, "packageJsonUpdates": {} } diff --git a/packages/gradle/migrations.spec.ts b/packages/gradle/migrations.spec.ts new file mode 100644 index 00000000000000..3862b363a8af74 --- /dev/null +++ b/packages/gradle/migrations.spec.ts @@ -0,0 +1,8 @@ +import json = require('./migrations.json'); + +import { assertValidMigrationPaths } from '@nx/devkit/internal-testing-utils'; +import { MigrationsJson } from '@nx/devkit'; + +describe('gradle migrations', () => { + assertValidMigrationPaths(json as MigrationsJson, __dirname); +}); diff --git a/packages/gradle/src/generators/init/init.ts b/packages/gradle/src/generators/init/init.ts index 163521f1d065a0..ae24c6831cfc1d 100644 --- a/packages/gradle/src/generators/init/init.ts +++ b/packages/gradle/src/generators/init/init.ts @@ -2,6 +2,7 @@ import { addDependenciesToPackageJson, formatFiles, GeneratorCallback, + globAsync, logger, readNxJson, runTasksInSerial, @@ -12,6 +13,7 @@ import { execSync } from 'child_process'; import { nxVersion } from '../../utils/versions'; import { InitGeneratorSchema } from './schema'; import { hasGradlePlugin } from '../../utils/has-gradle-plugin'; +import { dirname, join, basename } from 'path'; export async function initGenerator(tree: Tree, options: InitGeneratorSchema) { const tasks: GeneratorCallback[] = []; @@ -36,10 +38,9 @@ Running 'gradle init':`); ) ); } - + await addBuildGradleFileNextToSettingsGradle(tree); addPlugin(tree); updateNxJsonConfiguration(tree); - addProjectReportToBuildGradle(tree); if (!options.skipFormat) { await formatFiles(tree); @@ -66,23 +67,39 @@ function addPlugin(tree: Tree) { } /** - * This function adds the project-report plugin to the build.gradle or build.gradle.kts file + * This function creates and populate build.gradle file next to the settings.gradle file. */ -function addProjectReportToBuildGradle(tree: Tree) { - let buildGradleFile: string; - if (tree.exists('settings.gradle.kts')) { - buildGradleFile = 'build.gradle.kts'; - } else if (tree.exists('settings.gradle')) { - buildGradleFile = 'build.gradle'; - } +export async function addBuildGradleFileNextToSettingsGradle(tree: Tree) { + const settingsGradleFiles = await globAsync(tree, [ + '**/settings.gradle?(.kts)', + ]); + settingsGradleFiles.forEach((settingsGradleFile) => { + addProjectReportToBuildGradle(settingsGradleFile, tree); + }); +} +/** + * - creates a build.gradle file next to the settings.gradle file if it does not exist. + * - adds the project-report plugin to the build.gradle file if it does not exist. + * - adds a task to generate project reports for all subprojects and included builds. + */ +function addProjectReportToBuildGradle(settingsGradleFile: string, tree: Tree) { + const filename = basename(settingsGradleFile); + let gradleFilePath = 'build.gradle'; + if (filename.endsWith('.kts')) { + gradleFilePath = 'build.gradle.kts'; + } + gradleFilePath = join(dirname(settingsGradleFile), gradleFilePath); let buildGradleContent = ''; - if (tree.exists(buildGradleFile)) { - buildGradleContent = tree.read(buildGradleFile).toString(); + if (!tree.exists(gradleFilePath)) { + tree.write(gradleFilePath, buildGradleContent); // create a build.gradle file near settings.gradle file if it does not exist + } else { + buildGradleContent = tree.read(gradleFilePath).toString(); } + if (buildGradleContent.includes('allprojects')) { - if (!buildGradleContent.includes('"project-report')) { - logger.warn(`Please add the project-report plugin to your ${buildGradleFile}: + if (!buildGradleContent.includes('"project-report"')) { + logger.warn(`Please add the project-report plugin to your ${gradleFilePath}: allprojects { apply { plugin("project-report") @@ -95,7 +112,37 @@ allprojects { plugin("project-report") } }`; - tree.write(buildGradleFile, buildGradleContent); + } + + if (!buildGradleContent.includes(`tasks.register("projectReportAll")`)) { + if (gradleFilePath.endsWith('.kts')) { + buildGradleContent += `\n\rtasks.register("projectReportAll") { + // All project reports of subprojects + allprojects.forEach { + dependsOn(it.tasks.get("projectReport")) + } + + // All projectReportAll of included builds + gradle.includedBuilds.forEach { + dependsOn(it.task(":projectReportAll")) + } +}`; + } else { + buildGradleContent += `\n\rtasks.register("projectReportAll") { + // All project reports of subprojects + allprojects.forEach { + dependsOn(it.tasks.getAt("projectReport")) + } + + // All projectReportAll of included builds + gradle.includedBuilds.forEach { + dependsOn(it.task(":projectReportAll")) + } + }`; + } + } + if (buildGradleContent) { + tree.write(gradleFilePath, buildGradleContent); } } diff --git a/packages/gradle/src/migrations/19-4-0/add-project-report-all.spec.ts b/packages/gradle/src/migrations/19-4-0/add-project-report-all.spec.ts new file mode 100644 index 00000000000000..2aa06608dff4dc --- /dev/null +++ b/packages/gradle/src/migrations/19-4-0/add-project-report-all.spec.ts @@ -0,0 +1,28 @@ +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Tree } from '@nx/devkit'; + +import update from './add-project-report-all'; + +describe('AddProjectReportAll', () => { + let tree: Tree; + + beforeAll(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should update build.gradle', async () => { + tree.write('settings.gradle', ''); + await update(tree); + const buildGradle = tree.read('build.gradle').toString(); + expect(buildGradle).toContain('project-report'); + expect(buildGradle).toContain('projectReportAll'); + }); + + it('should update build.gradle.kts', async () => { + tree.write('settings.gradle.kts', ''); + await update(tree); + const buildGradle = tree.read('build.gradle.kts').toString(); + expect(buildGradle).toContain('project-report'); + expect(buildGradle).toContain('projectReportAll'); + }); +}); diff --git a/packages/gradle/src/migrations/19-4-0/add-project-report-all.ts b/packages/gradle/src/migrations/19-4-0/add-project-report-all.ts new file mode 100644 index 00000000000000..0d9824e95e5270 --- /dev/null +++ b/packages/gradle/src/migrations/19-4-0/add-project-report-all.ts @@ -0,0 +1,9 @@ +import { Tree } from '@nx/devkit'; +import { addBuildGradleFileNextToSettingsGradle } from '../../generators/init/init'; + +/** + * This migration adds task `projectReportAll` to build.gradle files + */ +export default async function update(tree: Tree) { + await addBuildGradleFileNextToSettingsGradle(tree); +} diff --git a/packages/gradle/src/plugin/dependencies.spec.ts b/packages/gradle/src/plugin/dependencies.spec.ts new file mode 100644 index 00000000000000..f1fff883a9693f --- /dev/null +++ b/packages/gradle/src/plugin/dependencies.spec.ts @@ -0,0 +1,72 @@ +import { join } from 'path'; +import { processGradleDependencies } from './dependencies'; + +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + validateDependency: jest.fn().mockReturnValue(true), +})); + +describe('processGradleDependencies', () => { + it('should process gradle dependencies with composite build', () => { + const depFilePath = join( + __dirname, + '..', + 'utils/__mocks__/gradle-composite-dependencies.txt' + ); + const dependencies = new Set([]); + processGradleDependencies( + depFilePath, + new Map([ + [':my-utils:number-utils', 'number-utils'], + [':my-utils:string-utils', 'string-utils'], + ]), + 'app', + 'app', + {} as any, + dependencies + ); + expect(Array.from(dependencies)).toEqual([ + { + source: 'app', + sourceFile: 'app', + target: 'number-utils', + type: 'static', + }, + { + source: 'app', + sourceFile: 'app', + target: 'string-utils', + type: 'static', + }, + ]); + }); + + it('should process gradle dependencies with regular build', () => { + const depFilePath = join( + __dirname, + '..', + 'utils/__mocks__/gradle-dependencies.txt' + ); + const dependencies = new Set([]); + processGradleDependencies( + depFilePath, + new Map([ + [':my-utils:number-utils', 'number-utils'], + [':my-utils:string-utils', 'string-utils'], + [':utilities', 'utilities'], + ]), + 'app', + 'app', + {} as any, + dependencies + ); + expect(Array.from(dependencies)).toEqual([ + { + source: 'app', + sourceFile: 'app', + target: 'utilities', + type: 'static', + }, + ]); + }); +}); diff --git a/packages/gradle/src/plugin/dependencies.ts b/packages/gradle/src/plugin/dependencies.ts index 66f0d6a3bf76c5..b07163165eb5ba 100644 --- a/packages/gradle/src/plugin/dependencies.ts +++ b/packages/gradle/src/plugin/dependencies.ts @@ -23,13 +23,13 @@ export const createDependencies: CreateDependencies = async ( return []; } - let dependencies: RawProjectGraphDependency[] = []; const gradleDependenciesStart = performance.mark('gradleDependencies:start'); const { gradleFileToGradleProjectMap, gradleProjectToProjectName, buildFileToDepsMap, } = getCurrentGradleReport(); + const dependencies: Set = new Set(); for (const gradleFile of gradleFiles) { const gradleProject = gradleFileToGradleProjectMap.get(gradleFile); @@ -37,19 +37,17 @@ export const createDependencies: CreateDependencies = async ( const depsFile = buildFileToDepsMap.get(gradleFile); if (projectName && depsFile) { - dependencies = dependencies.concat( - Array.from( - processGradleDependencies( - depsFile, - gradleProjectToProjectName, - projectName, - gradleFile, - context - ) - ) + processGradleDependencies( + depsFile, + gradleProjectToProjectName, + projectName, + gradleFile, + context, + dependencies ); } } + const gradleDependenciesEnd = performance.mark('gradleDependencies:end'); performance.measure( 'gradleDependencies', @@ -57,7 +55,7 @@ export const createDependencies: CreateDependencies = async ( gradleDependenciesEnd.name ); - return dependencies; + return Array.from(dependencies); }; const gradleConfigFileNames = new Set(['build.gradle', 'build.gradle.kts']); @@ -76,14 +74,14 @@ function findGradleFiles(fileMap: FileMap): string[] { return gradleFiles; } -function processGradleDependencies( +export function processGradleDependencies( depsFile: string, gradleProjectToProjectName: Map, sourceProjectName: string, gradleFile: string, - context: CreateDependenciesContext -): Set { - const dependencies: Set = new Set(); + context: CreateDependenciesContext, + dependencies: Set +): void { const lines = readFileSync(depsFile).toString().split(newLineSeparator); let inDeps = false; for (const line of lines) { @@ -101,24 +99,31 @@ function processGradleDependencies( continue; } const [indents, dep] = line.split('--- '); - if ((indents === '\\' || indents === '+') && dep.startsWith('project ')) { - const gradleProjectName = dep - .substring('project '.length) - .replace(/ \(n\)$/, '') - .trim(); + if (indents === '\\' || indents === '+') { + let gradleProjectName: string | undefined; + if (dep.startsWith('project ')) { + gradleProjectName = dep + .substring('project '.length) + .replace(/ \(n\)$/, '') + .trim(); + } else if (dep.includes('-> project')) { + const [_, projectName] = dep.split('-> project'); + gradleProjectName = projectName.trim(); + } const target = gradleProjectToProjectName.get( gradleProjectName ) as string; - const dependency: RawProjectGraphDependency = { - source: sourceProjectName, - target, - type: DependencyType.static, - sourceFile: gradleFile, - }; - validateDependency(dependency, context); - dependencies.add(dependency); + if (target) { + const dependency: RawProjectGraphDependency = { + source: sourceProjectName, + target, + type: DependencyType.static, + sourceFile: gradleFile, + }; + validateDependency(dependency, context); + dependencies.add(dependency); + } } } } - return dependencies; } diff --git a/packages/gradle/src/utils/__mocks__/gradle-composite-dependencies.txt b/packages/gradle/src/utils/__mocks__/gradle-composite-dependencies.txt new file mode 100644 index 00000000000000..afa14f41be81f6 --- /dev/null +++ b/packages/gradle/src/utils/__mocks__/gradle-composite-dependencies.txt @@ -0,0 +1,60 @@ + +------------------------------------------------------------ +Project ':my-app:app' +------------------------------------------------------------ + +annotationProcessor - Annotation processors and their dependencies for source set 'main'. +No dependencies + +compileClasspath - Compile classpath for source set 'main'. ++--- org.sample:number-utils:1.0 -> project :my-utils:number-utils +\--- org.sample:string-utils:1.0 -> project :my-utils:string-utils + +compileOnly - Compile-only dependencies for the 'main' feature. (n) +No dependencies + +default - Configuration for default artifacts. (n) +No dependencies + +implementation - Implementation dependencies for the 'main' feature. (n) ++--- org.sample:number-utils:1.0 (n) +\--- org.sample:string-utils:1.0 (n) + +mainSourceElements - List of source directories contained in the Main SourceSet. (n) +No dependencies + +runtimeClasspath - Runtime classpath of source set 'main'. ++--- org.sample:number-utils:1.0 -> project :my-utils:number-utils +\--- org.sample:string-utils:1.0 -> project :my-utils:string-utils + \--- org.apache.commons:commons-lang3:3.4 + +runtimeElements - Runtime elements for the 'main' feature. (n) +No dependencies + +runtimeOnly - Runtime-only dependencies for the 'main' feature. (n) +No dependencies + +testAnnotationProcessor - Annotation processors and their dependencies for source set 'test'. +No dependencies + +testCompileClasspath - Compile classpath for source set 'test'. ++--- org.sample:number-utils:1.0 -> project :my-utils:number-utils +\--- org.sample:string-utils:1.0 -> project :my-utils:string-utils + +testCompileOnly - Compile only dependencies for source set 'test'. (n) +No dependencies + +testImplementation - Implementation only dependencies for source set 'test'. (n) +No dependencies + +testRuntimeClasspath - Runtime classpath of source set 'test'. ++--- org.sample:number-utils:1.0 -> project :my-utils:number-utils +\--- org.sample:string-utils:1.0 -> project :my-utils:string-utils + \--- org.apache.commons:commons-lang3:3.4 + +testRuntimeOnly - Runtime only dependencies for source set 'test'. (n) +No dependencies + +(n) - A dependency or dependency configuration that cannot be resolved. + +A web-based, searchable dependency report is available by adding the --scan option. diff --git a/packages/gradle/src/utils/__mocks__/gradle-dependencies.txt b/packages/gradle/src/utils/__mocks__/gradle-dependencies.txt new file mode 100644 index 00000000000000..71a48578bba3bf --- /dev/null +++ b/packages/gradle/src/utils/__mocks__/gradle-dependencies.txt @@ -0,0 +1,121 @@ + +------------------------------------------------------------ +Project ':app' +------------------------------------------------------------ + +annotationProcessor - Annotation processors and their dependencies for source set 'main'. +No dependencies + +compileClasspath - Compile classpath for source set 'main'. ++--- org.apache.commons:commons-text -> 1.11.0 +| \--- org.apache.commons:commons-lang3:3.13.0 ++--- project :utilities +| \--- project :list +\--- org.apache.commons:commons-text:1.11.0 (c) + +compileOnly - Compile-only dependencies for the 'main' feature. (n) +No dependencies + +default - Configuration for default artifacts. (n) +No dependencies + +implementation - Implementation dependencies for the 'main' feature. (n) ++--- org.apache.commons:commons-text (n) +\--- project utilities (n) + +mainSourceElements - List of source directories contained in the Main SourceSet. (n) +No dependencies + +runtimeClasspath - Runtime classpath of source set 'main'. ++--- org.apache.commons:commons-text -> 1.11.0 +| \--- org.apache.commons:commons-lang3:3.13.0 ++--- project :utilities +| +--- project :list +| | \--- org.apache.commons:commons-text:1.11.0 (c) +| \--- org.apache.commons:commons-text:1.11.0 (c) +\--- org.apache.commons:commons-text:1.11.0 (c) + +runtimeElements - Runtime elements for the 'main' feature. (n) +No dependencies + +runtimeOnly - Runtime-only dependencies for the 'main' feature. (n) +No dependencies + +testAnnotationProcessor - Annotation processors and their dependencies for source set 'test'. +No dependencies + +testCompileClasspath - Compile classpath for source set 'test'. ++--- org.apache.commons:commons-text -> 1.11.0 +| \--- org.apache.commons:commons-lang3:3.13.0 ++--- project :utilities +| \--- project :list ++--- org.apache.commons:commons-text:1.11.0 (c) +\--- org.junit.jupiter:junit-jupiter:5.10.1 + +--- org.junit:junit-bom:5.10.1 + | +--- org.junit.jupiter:junit-jupiter:5.10.1 (c) + | +--- org.junit.jupiter:junit-jupiter-api:5.10.1 (c) + | +--- org.junit.jupiter:junit-jupiter-params:5.10.1 (c) + | \--- org.junit.platform:junit-platform-commons:1.10.1 (c) + +--- org.junit.jupiter:junit-jupiter-api:5.10.1 + | +--- org.junit:junit-bom:5.10.1 (*) + | +--- org.opentest4j:opentest4j:1.3.0 + | +--- org.junit.platform:junit-platform-commons:1.10.1 + | | +--- org.junit:junit-bom:5.10.1 (*) + | | \--- org.apiguardian:apiguardian-api:1.1.2 + | \--- org.apiguardian:apiguardian-api:1.1.2 + \--- org.junit.jupiter:junit-jupiter-params:5.10.1 + +--- org.junit:junit-bom:5.10.1 (*) + +--- org.junit.jupiter:junit-jupiter-api:5.10.1 (*) + \--- org.apiguardian:apiguardian-api:1.1.2 + +testCompileOnly - Compile only dependencies for source set 'test'. (n) +No dependencies + +testImplementation - Implementation only dependencies for source set 'test'. (n) +\--- org.junit.jupiter:junit-jupiter:5.10.1 (n) + +testRuntimeClasspath - Runtime classpath of source set 'test'. ++--- org.apache.commons:commons-text -> 1.11.0 +| \--- org.apache.commons:commons-lang3:3.13.0 ++--- project :utilities +| +--- project :list +| | \--- org.apache.commons:commons-text:1.11.0 (c) +| \--- org.apache.commons:commons-text:1.11.0 (c) ++--- org.apache.commons:commons-text:1.11.0 (c) ++--- org.junit.jupiter:junit-jupiter:5.10.1 +| +--- org.junit:junit-bom:5.10.1 +| | +--- org.junit.jupiter:junit-jupiter:5.10.1 (c) +| | +--- org.junit.jupiter:junit-jupiter-api:5.10.1 (c) +| | +--- org.junit.jupiter:junit-jupiter-engine:5.10.1 (c) +| | +--- org.junit.jupiter:junit-jupiter-params:5.10.1 (c) +| | +--- org.junit.platform:junit-platform-launcher:1.10.1 (c) +| | +--- org.junit.platform:junit-platform-commons:1.10.1 (c) +| | \--- org.junit.platform:junit-platform-engine:1.10.1 (c) +| +--- org.junit.jupiter:junit-jupiter-api:5.10.1 +| | +--- org.junit:junit-bom:5.10.1 (*) +| | +--- org.opentest4j:opentest4j:1.3.0 +| | \--- org.junit.platform:junit-platform-commons:1.10.1 +| | \--- org.junit:junit-bom:5.10.1 (*) +| +--- org.junit.jupiter:junit-jupiter-params:5.10.1 +| | +--- org.junit:junit-bom:5.10.1 (*) +| | \--- org.junit.jupiter:junit-jupiter-api:5.10.1 (*) +| \--- org.junit.jupiter:junit-jupiter-engine:5.10.1 +| +--- org.junit:junit-bom:5.10.1 (*) +| +--- org.junit.platform:junit-platform-engine:1.10.1 +| | +--- org.junit:junit-bom:5.10.1 (*) +| | +--- org.opentest4j:opentest4j:1.3.0 +| | \--- org.junit.platform:junit-platform-commons:1.10.1 (*) +| \--- org.junit.jupiter:junit-jupiter-api:5.10.1 (*) +\--- org.junit.platform:junit-platform-launcher -> 1.10.1 + +--- org.junit:junit-bom:5.10.1 (*) + \--- org.junit.platform:junit-platform-engine:1.10.1 (*) + +testRuntimeOnly - Runtime only dependencies for source set 'test'. (n) +\--- org.junit.platform:junit-platform-launcher (n) + +(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree. +(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation. + +(n) - A dependency or dependency configuration that cannot be resolved. + +A web-based, searchable dependency report is available by adding the --scan option. diff --git a/packages/gradle/src/utils/get-gradle-report.spec.ts b/packages/gradle/src/utils/get-gradle-report.spec.ts index 9e74b15cfe5002..4a23efc768df68 100644 --- a/packages/gradle/src/utils/get-gradle-report.spec.ts +++ b/packages/gradle/src/utils/get-gradle-report.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; import { join } from 'path'; -import { processProjectReports, fileSeparator } from './get-gradle-report'; +import { processProjectReports } from './get-gradle-report'; describe('processProjectReports', () => { it('should process project reports', () => { diff --git a/packages/gradle/src/utils/get-gradle-report.ts b/packages/gradle/src/utils/get-gradle-report.ts index c868c67f93a4d9..d3c2bb645a9ad5 100644 --- a/packages/gradle/src/utils/get-gradle-report.ts +++ b/packages/gradle/src/utils/get-gradle-report.ts @@ -1,7 +1,12 @@ import { existsSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; -import { normalizePath, workspaceRoot } from '@nx/devkit'; +import { + AggregateCreateNodesError, + logger, + normalizePath, + workspaceRoot, +} from '@nx/devkit'; import { execGradleAsync } from './exec-gradle'; import { hashWithWorkspaceContext } from 'nx/src/utils/workspace-context'; @@ -49,13 +54,38 @@ export async function populateGradleReport( const gradleProjectReportStart = performance.mark( 'gradleProjectReport:start' ); - const projectReportLines = ( - await execGradleAsync(['projectReport'], { + let projectReportLines; + try { + projectReportLines = await execGradleAsync(['projectReportAll'], { cwd: workspaceRoot, - }) - ) + }); + } catch (e) { + try { + projectReportLines = await execGradleAsync(['projectReport'], { + cwd: workspaceRoot, + }); + logger.warn( + 'Could not run `projectReportAll` task. Ran `projectReport` instead. Please run `nx generate @nx/gradle:init` to generate the necessary tasks.' + ); + } catch (e) { + throw new AggregateCreateNodesError( + [ + [ + null, + new Error( + 'Could not run `projectReportAll` or `projectReport` task. Please run `nx generate @nx/gradle:init` to generate the necessary tasks.' + ), + ], + ], + [] + ); + } + } + projectReportLines = projectReportLines .toString() - .split(newLineSeparator); + .split(newLineSeparator) + .filter((line) => line.trim() !== ''); + const gradleProjectReportEnd = performance.mark('gradleProjectReport:end'); performance.measure( 'gradleProjectReport', @@ -72,10 +102,6 @@ export function processProjectReports( * Map of Gradle File path to Gradle Project Name */ const gradleFileToGradleProjectMap = new Map(); - /** - * Map of Gradle Project Name to Gradle File - */ - const gradleProjectToGradleFileMap = new Map(); const dependenciesMap = new Map(); /** * Map of Gradle Build File to tasks type map @@ -170,7 +196,6 @@ export function processProjectReports( gradleFileToOutputDirsMap.set(buildFile, outputDirMap); gradleFileToGradleProjectMap.set(buildFile, gradleProject); - gradleProjectToGradleFileMap.set(gradleProject, buildFile); gradleProjectToProjectName.set(gradleProject, projectName); } if (line.endsWith('taskReport')) { diff --git a/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts b/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts index 7e328956305390..425c2d9b26bec8 100644 --- a/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts +++ b/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts @@ -84,12 +84,11 @@ export function writeMinimalNxJson(host: Tree, version: string) { export function updateGitIgnore(host: Tree) { let contents = host.read('.gitignore', 'utf-8') ?? ''; - if (!contents.includes('.nx/installation')) { - contents = [contents, '.nx/installation'].join('\n'); - } - if (!contents.includes('.nx/cache')) { - contents = [contents, '.nx/cache'].join('\n'); - } + ['.nx/installation', '.nx/cache', '.nx/workspace-data'].forEach((file) => { + if (!contents.includes(file)) { + contents = [contents, file].join('\n'); + } + }); host.write('.gitignore', contents); } From a3322f76efbd1aa7b0c5b70ca5b1e5b0239f2164 Mon Sep 17 00:00:00 2001 From: Jason Jean Date: Mon, 24 Jun 2024 13:33:12 -0700 Subject: [PATCH 08/10] fix(bundling): use vite createNodes v2 for add plugin (#26662) ## Current Behavior vite createNodesV1 is used during init ## Expected Behavior vite createNodesV2 is used during init ## Related Issue(s) Fixes # --- packages/vite/src/generators/init/init.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vite/src/generators/init/init.ts b/packages/vite/src/generators/init/init.ts index ee3f2ee709f1c9..6a5018ec963646 100644 --- a/packages/vite/src/generators/init/init.ts +++ b/packages/vite/src/generators/init/init.ts @@ -7,10 +7,10 @@ import { Tree, updateNxJson, } from '@nx/devkit'; -import { addPluginV1 } from '@nx/devkit/src/utils/add-plugin'; +import { addPlugin } from '@nx/devkit/src/utils/add-plugin'; import { setupPathsPlugin } from '../setup-paths-plugin/setup-paths-plugin'; -import { createNodes } from '../../plugins/plugin'; +import { createNodesV2 } from '../../plugins/plugin'; import { InitGeneratorSchema } from './schema'; import { checkDependenciesInstalled, moveToDevDependencies } from './lib/utils'; @@ -61,11 +61,11 @@ export async function initGeneratorInternal( schema.addPlugin ??= addPluginDefault; if (schema.addPlugin) { - await addPluginV1( + await addPlugin( tree, await createProjectGraphAsync(), '@nx/vite/plugin', - createNodes, + createNodesV2, { buildTargetName: ['build', 'vite:build', 'vite-build'], testTargetName: ['test', 'vite:test', 'vite-test'], From 47dfdcfc6b36132701f2f8acda6d9e39e6d9e181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 25 Jun 2024 03:13:24 +0200 Subject: [PATCH 09/10] feat(webpack): add convert-to-inferred generator (#26621) ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes # --------- Co-authored-by: Jack Hsu --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/nx-api.json | 9 + docs/generated/packages-metadata.json | 9 + .../generators/convert-to-inferred.json | 30 + docs/shared/reference/sitemap.md | 1 + .../plugin-migrations/aggregate-log-util.ts | 4 + .../executor-to-plugin-migrator.ts | 62 +- packages/webpack/generators.json | 5 + .../convert-to-inferred.spec.ts.snap | 280 +++++ .../convert-to-inferred.spec.ts | 1096 +++++++++++++++++ .../convert-to-inferred.ts | 174 +++ .../convert-to-inferred/schema.json | 19 + .../convert-to-inferred/utils/ast.ts | 59 + .../utils/build-post-target-transformer.ts | 416 +++++++ .../convert-to-inferred/utils/index.ts | 3 + .../utils/serve-post-target-transformer.ts | 414 +++++++ .../convert-to-inferred/utils/types.ts | 13 + .../lib/apply-base-config.ts | 6 +- 18 files changed, 2594 insertions(+), 14 deletions(-) create mode 100644 docs/generated/packages/webpack/generators/convert-to-inferred.json create mode 100644 packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap create mode 100644 packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/schema.json create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/ast.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/index.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/types.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index f930fed36a0f77..feb57ce92a9483 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -9891,6 +9891,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/webpack/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 c17e269ef2f6b4..80596938760ec6 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -3242,6 +3242,15 @@ "originalFilePath": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/schema.json", "path": "/nx-api/webpack/generators/convert-config-to-webpack-plugin", "type": "generator" + }, + "/nx-api/webpack/generators/convert-to-inferred": { + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "file": "generated/packages/webpack/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/webpack/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/webpack/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/webpack" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 349f07543e3e1c..8d5cb8974a3997 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -3207,6 +3207,15 @@ "originalFilePath": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/schema.json", "path": "webpack/generators/convert-config-to-webpack-plugin", "type": "generator" + }, + { + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "file": "generated/packages/webpack/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/webpack/src/generators/convert-to-inferred/schema.json", + "path": "webpack/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/webpack/generators/convert-to-inferred.json b/docs/generated/packages/webpack/generators/convert-to-inferred.json new file mode 100644 index 00000000000000..0d88dc6d75b22a --- /dev/null +++ b/docs/generated/packages/webpack/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred#convertToInferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxWebpackConvertToInferred", + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "title": "Convert a Webpack project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/webpack:webpack` executor to use `@nx/webpack/plugin`. If not provided, all projects using the `@nx/webpack:webpack` executor will be converted.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "implementation": "/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred#convertToInferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/webpack/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index fa66bb18bdc8be..c0fe925927cdf1 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -712,6 +712,7 @@ - [init](/nx-api/webpack/generators/init) - [configuration](/nx-api/webpack/generators/configuration) - [convert-config-to-webpack-plugin](/nx-api/webpack/generators/convert-config-to-webpack-plugin) + - [convert-to-inferred](/nx-api/webpack/generators/convert-to-inferred) - [workspace](/nx-api/workspace) - [documents](/nx-api/workspace/documents) - [Overview](/nx-api/workspace/documents/overview) diff --git a/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts b/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts index 9d58bef4b88f3f..9b6187ee5e97ad 100644 --- a/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts +++ b/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts @@ -44,6 +44,10 @@ export class AggregatedLog { } flushLogs(): void { + if (this.logs.size === 0) { + return; + } + let fullLog = ''; for (const executorName of this.logs.keys()) { fullLog = `${fullLog}${output.bold( 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 ee1b647c2ea0e2..0b663fb0ee6fb9 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 @@ -27,7 +27,10 @@ import { ProjectConfigurationsError, } from 'nx/src/devkit-internals'; import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils'; -import type { InputDefinition } from 'nx/src/config/workspace-json-project-json'; +import type { + InputDefinition, + ProjectConfiguration, +} from 'nx/src/config/workspace-json-project-json'; type PluginOptionsBuilder = (targetName: string) => T; type PostTargetTransformer = ( @@ -38,7 +41,10 @@ type PostTargetTransformer = ( ) => TargetConfiguration | Promise; type SkipTargetFilter = ( targetConfiguration: TargetConfiguration -) => [boolean, string]; +) => false | string; +type SkipProjectFilter = ( + projectConfiguration: ProjectConfiguration +) => false | string; class ExecutorToPluginMigrator { readonly tree: Tree; @@ -48,6 +54,7 @@ class ExecutorToPluginMigrator { readonly #pluginOptionsBuilder: PluginOptionsBuilder; readonly #postTargetTransformer: PostTargetTransformer; readonly #skipTargetFilter: SkipTargetFilter; + readonly #skipProjectFilter: SkipProjectFilter; readonly #specificProjectToMigrate: string; #nxJson: NxJsonConfiguration; #targetDefaultsForExecutor: Partial; @@ -56,6 +63,7 @@ class ExecutorToPluginMigrator { #createNodes?: CreateNodes; #createNodesV2?: CreateNodesV2; #createNodesResultsForTargets: Map; + #skippedProjects: Set; constructor( tree: Tree, @@ -67,7 +75,10 @@ class ExecutorToPluginMigrator { createNodes?: CreateNodes, createNodesV2?: CreateNodesV2, specificProjectToMigrate?: string, - skipTargetFilter?: SkipTargetFilter + filters?: { + skipProjectFilter?: SkipProjectFilter; + skipTargetFilter?: SkipTargetFilter; + } ) { this.tree = tree; this.#projectGraph = projectGraph; @@ -78,7 +89,9 @@ class ExecutorToPluginMigrator { this.#createNodes = createNodes; this.#createNodesV2 = createNodesV2; this.#specificProjectToMigrate = specificProjectToMigrate; - this.#skipTargetFilter = skipTargetFilter ?? ((...args) => [false, '']); + this.#skipProjectFilter = + filters?.skipProjectFilter ?? ((...args) => false); + this.#skipTargetFilter = filters?.skipTargetFilter ?? ((...args) => false); } async run(): Promise>> { @@ -99,6 +112,7 @@ class ExecutorToPluginMigrator { this.#targetAndProjectsToMigrate = new Map(); this.#pluginToAddForTarget = new Map(); this.#createNodesResultsForTargets = new Map(); + this.#skippedProjects = new Set(); this.#getTargetDefaultsForExecutor(); this.#getTargetAndProjectsToMigrate(); @@ -311,7 +325,7 @@ class ExecutorToPluginMigrator { this.tree, this.#executor, (targetConfiguration, projectName, targetName, configurationName) => { - if (configurationName) { + if (this.#skippedProjects.has(projectName) || configurationName) { return; } @@ -322,10 +336,23 @@ class ExecutorToPluginMigrator { return; } - const [skipTarget, reasonTargetWasSkipped] = - this.#skipTargetFilter(targetConfiguration); - if (skipTarget) { - const errorMsg = `${targetName} target on project "${projectName}" cannot be migrated. ${reasonTargetWasSkipped}`; + const skipProjectReason = this.#skipProjectFilter( + this.#projectGraph.nodes[projectName].data + ); + if (skipProjectReason) { + this.#skippedProjects.add(projectName); + const errorMsg = `The "${projectName}" project cannot be migrated. ${skipProjectReason}`; + if (this.#specificProjectToMigrate) { + throw new Error(errorMsg); + } + + console.warn(errorMsg); + return; + } + + const skipTargetReason = this.#skipTargetFilter(targetConfiguration); + if (skipTargetReason) { + const errorMsg = `${targetName} target on project "${projectName}" cannot be migrated. ${skipTargetReason}`; if (this.#specificProjectToMigrate) { throw new Error(errorMsg); } else { @@ -375,6 +402,7 @@ class ExecutorToPluginMigrator { return; } + global.NX_GRAPH_CREATION = true; for (const targetName of this.#targetAndProjectsToMigrate.keys()) { const loadedPlugin = new LoadedNxPlugin( { @@ -398,12 +426,14 @@ class ExecutorToPluginMigrator { if (e instanceof ProjectConfigurationsError) { projectConfigs = e.partialProjectConfigurationsResult; } else { + global.NX_GRAPH_CREATION = false; throw e; } } this.#createNodesResultsForTargets.set(targetName, projectConfigs); } + global.NX_GRAPH_CREATION = false; } } @@ -416,7 +446,10 @@ export async function migrateExecutorToPlugin( postTargetTransformer: PostTargetTransformer, createNodes: CreateNodesV2, specificProjectToMigrate?: string, - skipTargetFilter?: SkipTargetFilter + filters?: { + skipProjectFilter?: SkipProjectFilter; + skipTargetFilter?: SkipTargetFilter; + } ): Promise>> { const migrator = new ExecutorToPluginMigrator( tree, @@ -428,7 +461,7 @@ export async function migrateExecutorToPlugin( undefined, createNodes, specificProjectToMigrate, - skipTargetFilter + filters ); return await migrator.run(); } @@ -442,7 +475,10 @@ export async function migrateExecutorToPluginV1( postTargetTransformer: PostTargetTransformer, createNodes: CreateNodes, specificProjectToMigrate?: string, - skipTargetFilter?: SkipTargetFilter + filters?: { + skipProjectFilter?: SkipProjectFilter; + skipTargetFilter?: SkipTargetFilter; + } ): Promise>> { const migrator = new ExecutorToPluginMigrator( tree, @@ -454,7 +490,7 @@ export async function migrateExecutorToPluginV1( createNodes, undefined, specificProjectToMigrate, - skipTargetFilter + filters ); return await migrator.run(); } diff --git a/packages/webpack/generators.json b/packages/webpack/generators.json index 7bdc22df4ca567..a068087f35be86 100644 --- a/packages/webpack/generators.json +++ b/packages/webpack/generators.json @@ -20,6 +20,11 @@ "factory": "./src/generators/convert-config-to-webpack-plugin/convert-config-to-webpack-plugin", "schema": "./src/generators/convert-config-to-webpack-plugin/schema.json", "description": "Convert the project to use the `NxAppWebpackPlugin` and `NxReactWebpackPlugin`." + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred#convertToInferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`." } } } diff --git a/packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap b/packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap new file mode 100644 index 00000000000000..9f67b2e19bd17b --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap @@ -0,0 +1,280 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 1`] = ` +"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); +const { useLegacyNxPlugin } = require('@nx/webpack'); + +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], +}; +const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], +}; + +/** + * @type{import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions), + ], +}); +" +`; + +exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 2`] = ` +"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); +const { useLegacyNxPlugin } = require('@nx/webpack'); + +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app2', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], +}; +const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], +}; + +/** + * @type{import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions), + ], +}); +" +`; + +exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 3`] = ` +"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); +const { useLegacyNxPlugin } = require('@nx/webpack'); + +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app3', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], +}; +const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], +}; + +/** + * @type{import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions), + ], +}); +" +`; diff --git a/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 00000000000000..266e28e52e2ad4 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,1096 @@ +import { + addProjectConfiguration, + joinPathFragments, + readNxJson, + readProjectConfiguration, + updateProjectConfiguration, + 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 } from 'nx/src/generators/utils/project-configuration'; +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; + }), +})); +jest.mock('nx/src/devkit-internals', () => ({ + ...jest.requireActual('nx/src/devkit-internals'), + getExecutorInformation: jest + .fn() + .mockImplementation((pkg, ...args) => + jest + .requireActual('nx/src/devkit-internals') + .getExecutorInformation('@nx/webpack', ...args) + ), +})); + +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 ProjectOptions { + appName: string; + appRoot: string; + buildTargetName: string; + buildExecutor: string; + serveTargetName: string; + serveExecutor: string; +} + +const defaultProjectOptions: ProjectOptions = { + appName: 'app1', + appRoot: 'apps/app1', + buildTargetName: 'build', + buildExecutor: '@nx/webpack:webpack', + serveTargetName: 'serve', + serveExecutor: '@nx/webpack:dev-server', +}; + +const defaultWebpackConfig = `const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); +const { useLegacyNxPlugin } = require('@nx/webpack'); + +// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js' +// Please check that the options here are correct as they were moved from the old webpack.config.js to this file. +const options = {}; + +/** + * @type{import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + plugins: [ + new NxAppWebpackPlugin(options), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), options), + ], +}); +`; + +function writeWebpackConfig( + tree: Tree, + projectRoot: string, + webpackConfig = defaultWebpackConfig +) { + tree.write(`${projectRoot}/webpack.config.js`, webpackConfig); + fs.createFileSync(`${projectRoot}/webpack.config.js`, webpackConfig); + jest.doMock(join(fs.tempDir, projectRoot, 'webpack.config.js'), () => ({}), { + virtual: true, + }); +} + +function createProject( + tree: Tree, + opts: Partial = {}, + extraTargetOptions?: Record> +) { + let projectOpts = { ...defaultProjectOptions, ...opts }; + const project: ProjectConfiguration = { + name: projectOpts.appName, + root: projectOpts.appRoot, + projectType: 'application', + targets: { + [projectOpts.buildTargetName]: { + executor: projectOpts.buildExecutor, + options: { + webpackConfig: `${projectOpts.appRoot}/webpack.config.js`, + compiler: 'babel', + outputPath: `dist/${projectOpts.appRoot}`, + index: `${projectOpts.appRoot}/src/index.html`, + baseHref: '/', + main: `${projectOpts.appRoot}/src/main.tsx`, + tsConfig: `${projectOpts.appRoot}/tsconfig.app.json`, + assets: [ + `${projectOpts.appRoot}/src/favicon.ico`, + `${projectOpts.appRoot}/src/assets`, + ], + styles: [`${projectOpts.appRoot}/src/styles.scss`], + scripts: [], + ...extraTargetOptions?.[projectOpts.buildTargetName], + }, + configurations: { + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: `${projectOpts.appRoot}/src/environments/environment.ts`, + with: `${projectOpts.appRoot}/src/environments/environment.prod.ts`, + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + defaultConfiguration: 'production', + }, + [projectOpts.serveTargetName]: { + executor: projectOpts.serveExecutor, + options: { + buildTarget: `${projectOpts.appName}:${projectOpts.buildTargetName}`, + hmr: true, + ssl: true, + sslCert: `${projectOpts.appRoot}/server.crt`, + sslKey: `${projectOpts.appRoot}/server.key`, + proxyConfig: `${projectOpts.appRoot}/proxy.conf.json`, + ...extraTargetOptions?.[projectOpts.serveTargetName], + }, + configurations: { + development: { + buildTarget: `${projectOpts.appName}:${projectOpts.buildTargetName}:development`, + open: true, + }, + production: { + buildTarget: `${projectOpts.appName}:${projectOpts.buildTargetName}:production`, + hmr: false, + }, + }, + defaultConfiguration: 'development', + }, + }, + }; + fs.createFileSync( + `${projectOpts.appRoot}/proxy.conf.json`, + `{ + "/api": { + "target": "http://localhost:3333", + "secure": false + } + }` + ); + + writeWebpackConfig(tree, projectOpts.appRoot, `module.exports = {};`); + + addProject(tree, project.name, project); + fs.createFileSync( + `${projectOpts.appRoot}/project.json`, + JSON.stringify(project) + ); + return project; +} + +describe('convert-to-inferred', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('webpack'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.cleanup(); + jest.resetModules(); + }); + + describe('--project', () => { + it('should not convert projects without the "webpackConfig" option set', async () => { + const project = createProject(tree); + delete project.targets.build.options.webpackConfig; + updateProjectConfiguration(tree, project.name, project); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await expect( + convertToInferred(tree, { project: project.name }) + ).rejects.toThrow(/missing in the project configuration/); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should not convert projects still using "composePlugins"', async () => { + const project = createProject(tree); + writeWebpackConfig( + tree, + project.root, + `const { composePlugins, withNx } = require('@nx/webpack'); + const { withReact } = require('@nx/react'); + + // Nx plugins for webpack. + module.exports = composePlugins( + withNx(), + withReact({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + (config) => { + return config; + } + ); + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await expect( + convertToInferred(tree, { project: project.name }) + ).rejects.toThrow(/@nx\/webpack:convert-config-to-webpack-plugin"/); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should not convert projects not using "NxAppWebpackPlugin"', async () => { + const project = createProject(tree); + writeWebpackConfig( + tree, + project.root, + `module.exports = { + entry: './src/main.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'main.bundle.js', + }, + }; + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await expect( + convertToInferred(tree, { project: project.name }) + ).rejects.toThrow(/webpack config/); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should register plugin in nx.json', async () => { + const project = createProject(tree); + writeWebpackConfig(tree, project.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const webpackPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && + plugin.plugin === '@nx/webpack/plugin' && + plugin.include?.length === 1 + ); + expect(webpackPlugin).toBeTruthy(); + expect(webpackPlugin.include).toEqual([`${project.root}/**/*`]); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should move options to the webpack config file', async () => { + const project = createProject(tree); + writeWebpackConfig(tree, project.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8')) + .toMatchInlineSnapshot(` + "const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); + const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); + const { useLegacyNxPlugin } = require('@nx/webpack'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions), + ], + }); + " + `); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should merge options into the options object in the webpack config file', async () => { + const project = createProject(tree, undefined, { + build: { + main: `${defaultProjectOptions.appRoot}/src/main.tsx`, + tsConfig: `${defaultProjectOptions.appRoot}/tsconfig.app.json`, + assets: [ + `${defaultProjectOptions.appRoot}/src/favicon.ico`, + `${defaultProjectOptions.appRoot}/src/public`, + ], + styles: [`${defaultProjectOptions.appRoot}/src/theme.scss`], + }, + }); + writeWebpackConfig( + tree, + project.root, + `const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); + const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); + const { useLegacyNxPlugin } = require('@nx/webpack'); + + // This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js' + // Please check that the options here are correct as they were moved from the old webpack.config.js to this file. + const options = { + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + memoryLimit: 4096, + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + plugins: [ + new NxAppWebpackPlugin(options), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), options), + ], + }); + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8')) + .toMatchInlineSnapshot(` + "const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); + const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); + const { useLegacyNxPlugin } = require('@nx/webpack'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + memoryLimit: 4096, + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions), + ], + }); + " + `); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should not touch the existing "devServer" option', async () => { + const project = createProject(tree); + writeWebpackConfig( + tree, + project.root, + `const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); + const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); + const { useLegacyNxPlugin } = require('@nx/webpack'); + + // This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js' + // Please check that the options here are correct as they were moved from the old webpack.config.js to this file. + const options = {}; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: { hot: true }, + plugins: [ + new NxAppWebpackPlugin(options), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), options), + ], + }); + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8')).toEqual( + expect.stringContaining(`// This is the untouched "devServer" option from the original webpack config. Please review it and make any necessary changes manually. + devServer: { hot: true },`) + ); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should keep the "port" value if set', async () => { + const project = createProject(tree, undefined, { + serve: { port: 1234 }, + }); + writeWebpackConfig(tree, project.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8')).toContain( + 'port: 1234,' + ); + expect( + tree.read(`${project.root}/webpack.config.js`, 'utf-8') + ).not.toContain('port: 4200,'); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + }); + + describe('all projects', () => { + it('should migrate all projects using the webpack executors', async () => { + const project1 = createProject(tree); + writeWebpackConfig(tree, project1.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + buildExecutor: '@nrwl/webpack:webpack', + serveExecutor: '@nrwl/webpack:dev-server', + }); + writeWebpackConfig(tree, project2.root); + const project3 = createProject(tree, { + appName: 'app3', + appRoot: 'apps/app3', + buildTargetName: 'build-webpack', + }); + writeWebpackConfig(tree, project3.root); + const projectWithComposePlugins = createProject(tree, { + appName: 'app4', + appRoot: 'apps/app4', + }); + const projectWithComposePluginsInitialTargets = + projectWithComposePlugins.targets; + const initialProjectWithComposePluginsWebpackConfig = `const { composePlugins, withNx } = require('@nx/webpack'); +const { withReact } = require('@nx/react'); + +// Nx plugins for webpack. +module.exports = composePlugins( + withNx(), + withReact({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + (config) => { + return config; + } +); +`; + writeWebpackConfig( + tree, + projectWithComposePlugins.root, + initialProjectWithComposePluginsWebpackConfig + ); + const projectWithNoNxAppWebpackPlugin = createProject(tree, { + appName: 'app5', + appRoot: 'apps/app5', + }); + const projectWithNoNxAppWebpackPluginInitialTargets = + projectWithNoNxAppWebpackPlugin.targets; + const initialProjectWithNoNxAppWebpackPluginWebpackConfig = `module.exports = { + entry: './src/main.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'main.bundle.js', + }, +}; +`; + writeWebpackConfig( + tree, + projectWithNoNxAppWebpackPlugin.root, + initialProjectWithNoNxAppWebpackPluginWebpackConfig + ); + + await convertToInferred(tree, {}); + + // project configurations + const updatedProject1 = readProjectConfiguration(tree, project1.name); + expect(updatedProject1.targets).toStrictEqual({ + build: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }, + serve: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'development', + }, + }); + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets).toStrictEqual({ + build: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }, + serve: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'development', + }, + }); + const updatedProject3 = readProjectConfiguration(tree, project3.name); + expect(updatedProject3.targets).toStrictEqual({ + 'build-webpack': { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }, + serve: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'development', + }, + }); + const updatedProjectWithComposePlugins = readProjectConfiguration( + tree, + projectWithComposePlugins.name + ); + expect(updatedProjectWithComposePlugins.targets).toStrictEqual( + projectWithComposePluginsInitialTargets + ); + const updatedProjectWithNoNxAppWebpackPlugin = readProjectConfiguration( + tree, + projectWithNoNxAppWebpackPlugin.name + ); + expect(updatedProjectWithNoNxAppWebpackPlugin.targets).toStrictEqual( + projectWithNoNxAppWebpackPluginInitialTargets + ); + // webpack config files + const project1WebpackConfig = tree.read( + `${project1.root}/webpack.config.js`, + 'utf-8' + ); + expect(project1WebpackConfig).toMatchSnapshot(); + const project2WebpackConfig = tree.read( + `${project2.root}/webpack.config.js`, + 'utf-8' + ); + expect(project2WebpackConfig).toMatchSnapshot(); + const project3WebpackConfig = tree.read( + `${project3.root}/webpack.config.js`, + 'utf-8' + ); + expect(project3WebpackConfig).toMatchSnapshot(); + const updatedProjectWithComposePluginsWebpackConfig = tree.read( + `${projectWithComposePlugins.root}/webpack.config.js`, + 'utf-8' + ); + expect(updatedProjectWithComposePluginsWebpackConfig).toBe( + initialProjectWithComposePluginsWebpackConfig + ); + const updatedProjectWithNoNxAppWebpackPluginWebpackConfig = tree.read( + `${projectWithNoNxAppWebpackPlugin.root}/webpack.config.js`, + 'utf-8' + ); + expect(updatedProjectWithNoNxAppWebpackPluginWebpackConfig).toBe( + initialProjectWithNoNxAppWebpackPluginWebpackConfig + ); + }); + + it('should keep the higher "memoryLimit" value in the build configuration', async () => { + const project = createProject(tree, undefined, { + build: { memoryLimit: 4096 }, + serve: { memoryLimit: 8192 }, // higher value, should be set in the build configuration + }); + writeWebpackConfig(tree, project.root); + const project2 = createProject( + tree, + { appName: 'app2', appRoot: 'apps/app2' }, + { + build: { memoryLimit: 8192 }, // higher value, should be kept in the build configuration + serve: { memoryLimit: 4096 }, + } + ); + writeWebpackConfig(tree, project2.root); + + await convertToInferred(tree, {}); + + // check the updated webpack config + expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8')) + .toMatchInlineSnapshot(` + "const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); + const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); + const { useLegacyNxPlugin } = require('@nx/webpack'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + memoryLimit: 8192, + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions), + ], + }); + " + `); + expect(tree.read(`${project2.root}/webpack.config.js`, 'utf-8')) + .toMatchInlineSnapshot(` + "const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); + const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); + const { useLegacyNxPlugin } = require('@nx/webpack'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app2', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + memoryLimit: 8192, + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + // eslint-disable-next-line react-hooks/rules-of-hooks + await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions), + ], + }); + " + `); + }); + }); +}); diff --git a/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 00000000000000..f3afe012ceec2b --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,174 @@ +import { + addDependenciesToPackageJson, + createProjectGraphAsync, + formatFiles, + type ProjectConfiguration, + runTasksInSerial, + type Tree, +} from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { migrateExecutorToPlugin } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import * as ts from 'typescript'; +import { createNodesV2, type WebpackPluginOptions } from '../../plugins/plugin'; +import { webpackCliVersion } from '../../utils/versions'; +import { + buildPostTargetTransformerFactory, + type MigrationContext, + servePostTargetTransformerFactory, +} from './utils'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + const migrationContext: MigrationContext = { + logger: new AggregatedLog(), + projectGraph, + workspaceRoot: tree.root, + }; + + // build + const migratedBuildProjects = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nx/webpack:webpack', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: targetName, + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: 'serve', + }), + buildPostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + const migratedBuildProjectsLegacy = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nrwl/webpack:webpack', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: targetName, + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: 'serve', + }), + buildPostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + + // serve + const migratedServeProjects = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nx/webpack:dev-server', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: 'build', + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: targetName, + }), + servePostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + const migratedServeProjectsLegacy = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nrwl/webpack:dev-server', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: 'build', + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: targetName, + }), + servePostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + + const migratedProjects = + migratedBuildProjects.size + + migratedBuildProjectsLegacy.size + + migratedServeProjects.size + + migratedServeProjectsLegacy.size; + + if (migratedProjects === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + const installCallback = addDependenciesToPackageJson( + tree, + {}, + { 'webpack-cli': webpackCliVersion }, + undefined, + true + ); + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(installCallback, () => { + migrationContext.logger.flushLogs(); + }); +} + +function skipProjectFilterFactory(tree: Tree) { + return function skipProjectFilter( + projectConfiguration: ProjectConfiguration + ): false | string { + const buildTarget = Object.values(projectConfiguration.targets).find( + (target) => + target.executor === '@nx/webpack:webpack' || + target.executor === '@nrwl/webpack:webpack' + ); + // the projects for which this is called are guaranteed to have a build target + const webpackConfigPath = buildTarget.options.webpackConfig; + if (!webpackConfigPath) { + return `The webpack config path is missing in the project configuration (${projectConfiguration.root}).`; + } + + const sourceFile = tsquery.ast(tree.read(webpackConfigPath, 'utf-8')); + + const composePluginsSelector = + 'CallExpression:has(Identifier[name=composePlugins])'; + const composePlugins = tsquery( + sourceFile, + composePluginsSelector + )[0]; + + if (composePlugins) { + return `The webpack config (${webpackConfigPath}) can only work with the "@nx/webpack:webpack" executor. Run the "@nx/webpack:convert-config-to-webpack-plugin" generator first.`; + } + + const nxAppWebpackPluginSelector = + 'PropertyAssignment:has(Identifier[name=plugins]) NewExpression:has(Identifier[name=NxAppWebpackPlugin])'; + const nxAppWebpackPlugin = tsquery( + sourceFile, + nxAppWebpackPluginSelector + )[0]; + + if (!nxAppWebpackPlugin) { + return `No "NxAppWebpackPlugin" found in the webpack config (${webpackConfigPath}). Its usage is required for the migration to work.`; + } + + return false; + }; +} diff --git a/packages/webpack/src/generators/convert-to-inferred/schema.json b/packages/webpack/src/generators/convert-to-inferred/schema.json new file mode 100644 index 00000000000000..7de0321ae1a675 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxWebpackConvertToInferred", + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "title": "Convert a Webpack project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/webpack:webpack` executor to use `@nx/webpack/plugin`. If not provided, all projects using the `@nx/webpack:webpack` executor will be converted.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files.", + "default": false + } + } +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/ast.ts b/packages/webpack/src/generators/convert-to-inferred/utils/ast.ts new file mode 100644 index 00000000000000..a6a3f9d9514e05 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/ast.ts @@ -0,0 +1,59 @@ +import * as ts from 'typescript'; + +export function toPropertyAssignment( + key: string, + value: unknown +): ts.PropertyAssignment { + if (typeof value === 'string') { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createStringLiteral(value) + ); + } else if (typeof value === 'number') { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createNumericLiteral(value) + ); + } else if (typeof value === 'boolean') { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + value ? ts.factory.createTrue() : ts.factory.createFalse() + ); + } else if (Array.isArray(value)) { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createArrayLiteralExpression( + value.map((item) => toExpression(item)) + ) + ); + } else { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ); + } +} + +export function toExpression(value: unknown): ts.Expression { + if (typeof value === 'string') { + return ts.factory.createStringLiteral(value); + } else if (typeof value === 'number') { + return ts.factory.createNumericLiteral(value); + } else if (typeof value === 'boolean') { + return value ? ts.factory.createTrue() : ts.factory.createFalse(); + } else if (Array.isArray(value)) { + return ts.factory.createArrayLiteralExpression( + value.map((item) => toExpression(item)) + ); + } else { + return ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ); + } +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts b/packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts new file mode 100644 index 00000000000000..342a3829e63c50 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts @@ -0,0 +1,416 @@ +import type { TargetConfiguration, Tree } from '@nx/devkit'; +import { + processTargetOutputs, + toProjectRelativePath, +} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import * as ts from 'typescript'; +import type { WebpackExecutorOptions } from '../../../executors/webpack/schema'; +import type { NxAppWebpackPluginOptions } from '../../../plugins/nx-webpack-plugin/nx-app-webpack-plugin-options'; +import { toPropertyAssignment } from './ast'; +import type { MigrationContext, TransformerContext } from './types'; + +export function buildPostTargetTransformerFactory( + migrationContext: MigrationContext +) { + return function buildPostTargetTransformer( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTarget: TargetConfiguration + ): TargetConfiguration { + const context: TransformerContext = { + ...migrationContext, + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }; + + const { pluginOptions, webpackConfigPath } = processOptions( + target, + context + ); + + updateWebpackConfig(tree, webpackConfigPath, pluginOptions); + + if (target.outputs) { + processTargetOutputs(target, [], inferredTarget, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; + }; +} + +type ExtractedOptions = { + default: NxAppWebpackPluginOptions; + [configName: string]: NxAppWebpackPluginOptions; +}; +function processOptions( + target: TargetConfiguration, + context: TransformerContext +): { + pluginOptions: ExtractedOptions; + webpackConfigPath: string; +} { + const webpackConfigPath = target.options.webpackConfig; + delete target.options.webpackConfig; + + const pluginOptions: ExtractedOptions = { + default: extractPluginOptions(target.options, context), + }; + + if (target.configurations && Object.keys(target.configurations).length) { + for (const [configName, config] of Object.entries(target.configurations)) { + pluginOptions[configName] = extractPluginOptions( + config, + context, + configName + ); + } + } + + return { pluginOptions, webpackConfigPath }; +} + +const pathOptions = new Set([ + 'babelConfig', + 'index', + 'main', + 'outputPath', + 'polyfills', + 'postcssConfig', + 'tsConfig', +]); +const assetsOptions = new Set(['assets', 'scripts', 'styles']); +function extractPluginOptions( + options: WebpackExecutorOptions, + context: TransformerContext, + configName?: string +): NxAppWebpackPluginOptions { + const pluginOptions: NxAppWebpackPluginOptions = {}; + + for (const [key, value] of Object.entries(options)) { + if (pathOptions.has(key)) { + pluginOptions[key] = toProjectRelativePath(value, context.projectRoot); + delete options[key]; + } else if (assetsOptions.has(key)) { + pluginOptions[key] = value.map((asset: string | { input: string }) => { + if (typeof asset === 'string') { + return toProjectRelativePath(asset, context.projectRoot); + } + + asset.input = toProjectRelativePath(asset.input, context.projectRoot); + return asset; + }); + delete options[key]; + } else if (key === 'fileReplacements') { + pluginOptions.fileReplacements = value.map( + (replacement: { replace: string; with: string }) => ({ + replace: toProjectRelativePath( + replacement.replace, + context.projectRoot + ), + with: toProjectRelativePath(replacement.with, context.projectRoot), + }) + ); + delete options.fileReplacements; + } else if (key === 'additionalEntryPoints') { + pluginOptions.additionalEntryPoints = value.map((entryPoint) => { + entryPoint.entryPath = toProjectRelativePath( + entryPoint.entryPath, + context.projectRoot + ); + return entryPoint; + }); + delete options.additionalEntryPoints; + } else if (key === 'memoryLimit') { + pluginOptions.memoryLimit = value; + const serveMemoryLimit = getMemoryLimitFromServeTarget( + context, + configName + ); + if (serveMemoryLimit) { + pluginOptions.memoryLimit = Math.max(serveMemoryLimit, value); + context.logger.addLog({ + executorName: '@nx/webpack:webpack', + log: `The "memoryLimit" option was set in both the serve and build configurations. The migration set the higher value to the build configuration and removed the option from the serve configuration.`, + project: context.projectName, + }); + } + delete options.memoryLimit; + } else if (key === 'isolatedConfig') { + context.logger.addLog({ + executorName: '@nx/webpack:webpack', + log: `The 'isolatedConfig' option is deprecated and not supported by the NxAppWebpackPlugin. It was removed from your project configuration.`, + project: context.projectName, + }); + delete options.isolatedConfig; + } else if (key === 'standardWebpackConfigFunction') { + delete options.standardWebpackConfigFunction; + } else { + pluginOptions[key] = value; + delete options[key]; + } + } + + return pluginOptions; +} + +function updateWebpackConfig( + tree: Tree, + webpackConfig: string, + pluginOptions: ExtractedOptions +): void { + let sourceFile: ts.SourceFile; + let webpackConfigText: string; + + const updateSources = () => { + webpackConfigText = tree.read(webpackConfig, 'utf-8'); + sourceFile = tsquery.ast(webpackConfigText); + }; + updateSources(); + + setOptionsInWebpackConfig( + tree, + webpackConfigText, + sourceFile, + webpackConfig, + pluginOptions + ); + updateSources(); + + setOptionsInNxWebpackPlugin( + tree, + webpackConfigText, + sourceFile, + webpackConfig + ); + updateSources(); + + setOptionsInLegacyNxPlugin( + tree, + webpackConfigText, + sourceFile, + webpackConfig + ); +} + +function setOptionsInWebpackConfig( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfig: string, + pluginOptions: ExtractedOptions +): void { + const { default: defaultOptions, ...configurationOptions } = pluginOptions; + + const optionsSelector = + 'VariableStatement:has(VariableDeclaration:has(Identifier[name=options]))'; + const optionsVariable = tsquery( + sourceFile, + optionsSelector + )[0]; + + // This is assuming the `options` variable will be available since it's what the + // `convert-config-to-webpack-plugin` generates + + let defaultOptionsObject: ts.ObjectLiteralExpression; + const optionsObject = tsquery( + optionsVariable, + 'ObjectLiteralExpression' + )[0]; + if (optionsObject.properties.length === 0) { + defaultOptionsObject = ts.factory.createObjectLiteralExpression( + Object.entries(defaultOptions).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ); + } else { + // filter out the default options that are already in the options object + // the existing options override the options from the project.json file + const filteredDefaultOptions = Object.entries(defaultOptions).filter( + ([key]) => + !optionsObject.properties.some( + (property) => + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === key + ) + ); + defaultOptionsObject = ts.factory.createObjectLiteralExpression([ + ...optionsObject.properties, + ...filteredDefaultOptions.map(([key, value]) => + toPropertyAssignment(key, value) + ), + ]); + } + + /** + * const configValues = { + * build: { + * default: { ... }, + * configuration1: { ... }, + * configuration2: { ... }, + * } + */ + const configValuesVariable = ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'configValues', + undefined, + undefined, + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + 'build', + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + 'default', + defaultOptionsObject + ), + ...(configurationOptions + ? Object.entries(configurationOptions).map(([key, value]) => + ts.factory.createPropertyAssignment( + key, + ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ) + ) + : []), + ]) + ), + ], + true + ) + ), + ], + ts.NodeFlags.Const + ) + ); + + text = `${text.slice(0, optionsVariable.getStart())} + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + ${ts + .createPrinter() + .printNode(ts.EmitHint.Unspecified, configValuesVariable, sourceFile)} + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + };${text.slice(optionsVariable.getEnd())}`; + + // These are comments written by the `convert-config-to-webpack-plugin` that are no longer needed + text = text + .replace( + `// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'`, + '' + ) + .replace( + '// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.', + '' + ); + + tree.write(webpackConfig, text); +} + +function setOptionsInNxWebpackPlugin( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfig: string +): void { + const nxAppWebpackPluginSelector = + 'PropertyAssignment:has(Identifier[name=plugins]) NewExpression:has(Identifier[name=NxAppWebpackPlugin])'; + const nxAppWebpackPlugin = tsquery( + sourceFile, + nxAppWebpackPluginSelector + )[0]; + + // the NxAppWebpackPlugin must exists, otherwise, the migration doesn't run and we wouldn't reach this point + const updatedNxAppWebpackPlugin = ts.factory.updateNewExpression( + nxAppWebpackPlugin, + nxAppWebpackPlugin.expression, + undefined, + [ts.factory.createIdentifier('buildOptions')] + ); + + text = `${text.slice(0, nxAppWebpackPlugin.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedNxAppWebpackPlugin, + sourceFile + )}${text.slice(nxAppWebpackPlugin.getEnd())}`; + + tree.write(webpackConfig, text); +} + +function setOptionsInLegacyNxPlugin( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfig: string +): void { + const legacyNxPluginSelector = + 'AwaitExpression CallExpression:has(Identifier[name=useLegacyNxPlugin])'; + const legacyNxPlugin = tsquery( + sourceFile, + legacyNxPluginSelector + )[0]; + + // we're assuming the `useLegacyNxPlugin` function is being called since it's what the `convert-config-to-webpack-plugin` generates + // we've already "ensured" that the `convert-config-to-webpack-plugin` was run by checking for the `NxAppWebpackPlugin` in the project validation + const updatedLegacyNxPlugin = ts.factory.updateCallExpression( + legacyNxPlugin, + legacyNxPlugin.expression, + undefined, + [legacyNxPlugin.arguments[0], ts.factory.createIdentifier('buildOptions')] + ); + + text = `${text.slice(0, legacyNxPlugin.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedLegacyNxPlugin, + sourceFile + )}${text.slice(legacyNxPlugin.getEnd())}`; + + tree.write(webpackConfig, text); +} + +function getMemoryLimitFromServeTarget( + context: TransformerContext, + configName: string | undefined +): number | undefined { + const { targets } = context.projectGraph.nodes[context.projectName].data; + + const serveTarget = Object.values(targets).find( + (target) => + target.executor === '@nx/webpack:dev-server' || + target.executor === '@nrwl/web:dev-server' + ); + + if (!serveTarget) { + return undefined; + } + + if (configName && serveTarget.configurations?.[configName]) { + return ( + serveTarget.configurations[configName].options?.memoryLimit ?? + serveTarget.options?.memoryLimit + ); + } + + return serveTarget.options?.memoryLimit; +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/index.ts b/packages/webpack/src/generators/convert-to-inferred/utils/index.ts new file mode 100644 index 00000000000000..a453ed810be55a --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/index.ts @@ -0,0 +1,3 @@ +export * from './build-post-target-transformer'; +export * from './serve-post-target-transformer'; +export * from './types'; diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts b/packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts new file mode 100644 index 00000000000000..9d8e3600f3de69 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts @@ -0,0 +1,414 @@ +import { + parseTargetString, + readJson, + readTargetOptions, + type ExecutorContext, + type ProjectsConfigurations, + type TargetConfiguration, + type Tree, +} from '@nx/devkit'; +import { + processTargetOutputs, + toProjectRelativePath, +} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { basename, resolve } from 'path'; +import * as ts from 'typescript'; +import type { WebpackOptionsNormalized } from 'webpack'; +import { buildServePath } from '../../../executors/dev-server/lib/serve-path'; +import type { WebDevServerOptions as DevServerExecutorOptions } from '../../../executors/dev-server/schema'; +import { toPropertyAssignment } from './ast'; +import type { MigrationContext, TransformerContext } from './types'; + +export function servePostTargetTransformerFactory( + migrationContext: MigrationContext +) { + return async function servePostTargetTransformer( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTarget: TargetConfiguration + ): Promise { + const context: TransformerContext = { + ...migrationContext, + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }; + + const { devServerOptions, webpackConfigPath } = await processOptions( + tree, + target, + context + ); + + updateWebpackConfig(tree, webpackConfigPath, devServerOptions, context); + + if (target.outputs) { + processTargetOutputs(target, [], inferredTarget, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; + }; +} + +type WebpackConfigDevServerOptions = WebpackOptionsNormalized['devServer']; +type ExtractedOptions = { + default: WebpackConfigDevServerOptions; + [configName: string]: WebpackConfigDevServerOptions; +}; + +async function processOptions( + tree: Tree, + target: TargetConfiguration, + context: TransformerContext +): Promise<{ + devServerOptions: ExtractedOptions; + webpackConfigPath: string; +}> { + const executorContext = { + cwd: process.cwd(), + nxJsonConfiguration: readJson(tree, 'nx.json'), + projectGraph: context.projectGraph, + projectName: context.projectName, + projectsConfigurations: Object.entries(context.projectGraph.nodes).reduce( + (acc, [projectName, project]) => { + acc.projects[projectName] = project.data; + return acc; + }, + { version: 1, projects: {} } as ProjectsConfigurations + ), + root: context.workspaceRoot, + } as ExecutorContext; + const buildTarget = parseTargetString( + target.options.buildTarget, + executorContext + ); + const buildOptions = readTargetOptions(buildTarget, executorContext); + + // it must exist, we validated it in the project filter + const webpackConfigPath = buildOptions.webpackConfig; + + const defaultOptions = extractDevServerOptions(target.options, context); + applyDefaults(defaultOptions, buildOptions); + const devServerOptions: ExtractedOptions = { + default: defaultOptions, + }; + + if (target.configurations && Object.keys(target.configurations).length) { + for (const [configName, config] of Object.entries(target.configurations)) { + devServerOptions[configName] = extractDevServerOptions(config, context); + } + } + + return { devServerOptions, webpackConfigPath }; +} + +function extractDevServerOptions( + options: DevServerExecutorOptions, + context: TransformerContext +): WebpackConfigDevServerOptions { + const devServerOptions: WebpackConfigDevServerOptions = {}; + + for (const [key, value] of Object.entries(options)) { + if (key === 'hmr') { + devServerOptions.hot = value; + + if (value) { + // the executor disables liveReload when hmr is enabled + devServerOptions.liveReload = false; + delete options.liveReload; + } + + delete options.hmr; + } else if (key === 'allowedHosts') { + devServerOptions.allowedHosts = value.split(','); + delete options.allowedHosts; + } else if (key === 'publicHost') { + devServerOptions.client = { + webSocketURL: value, + }; + delete options.publicHost; + } else if (key === 'proxyConfig') { + devServerOptions.proxy = getProxyConfig(context.workspaceRoot, value); + delete options.proxyConfig; + } else if (key === 'ssl' || key === 'sslCert' || key === 'sslKey') { + if (key === 'ssl' || 'ssl' in options) { + if (options.ssl) { + devServerOptions.server = { type: 'https' }; + + if (options.sslCert && options.sslKey) { + devServerOptions.server.options = {}; + devServerOptions.server.options.cert = toProjectRelativePath( + options.sslCert, + context.projectRoot + ); + devServerOptions.server.options.key = toProjectRelativePath( + options.sslKey, + context.projectRoot + ); + } else if (options.sslCert) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslCert" option was set but "sslKey" was missing and "ssl" was set to "true". This means that "sslCert" was ignored by the executor. It has been removed from the options.', + project: context.projectName, + }); + } else if (options.sslKey) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslKey" option was set but "sslCert" was missing and "ssl" was set to "true". This means that "sslKey" was ignored by the executor. It has been removed from the options.', + project: context.projectName, + }); + } + } else if (options.sslCert || options.sslKey) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslCert" and/or "sslKey" were set with "ssl" set to "false". This means they were ignored by the executor. They have been removed from the options.', + project: context.projectName, + }); + } + delete options.ssl; + delete options.sslCert; + delete options.sslKey; + } else if (options.sslCert || options.sslKey) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslCert" and/or "sslKey" were set but the "ssl" was not set. This means they were ignored by the executor. They have been removed from the options.', + project: context.projectName, + }); + delete options.sslCert; + delete options.sslKey; + } + } else if (key === 'buildTarget') { + delete options.buildTarget; + } else if (key === 'watch') { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "watch" option was removed from the serve configuration since it is not needed. The dev server always watches the files.', + project: context.projectName, + }); + delete options.watch; + } else if (key === 'baseHref') { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "baseHref" option was removed from the serve configuration. If you need different base hrefs for the build and the dev server, please update the final webpack config manually to achieve that.', + project: context.projectName, + }); + delete options.baseHref; + } else if (key === 'memoryLimit') { + // we already log a message for this one when processing the build options + delete options.memoryLimit; + } else { + devServerOptions[key] = value; + delete options[key]; + } + } + + return devServerOptions; +} + +function applyDefaults( + options: WebpackConfigDevServerOptions, + buildOptions: any +) { + if (options.port === undefined) { + options.port = 4200; + } + + options.headers = { 'Access-Control-Allow-Origin': '*' }; + + const servePath = buildServePath(buildOptions); + options.historyApiFallback = { + index: buildOptions.index && `${servePath}${basename(buildOptions.index)}`, + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }; +} + +function getProxyConfig(root: string, proxyConfig: string) { + const proxyPath = resolve(root, proxyConfig); + return require(proxyPath); +} + +function updateWebpackConfig( + tree: Tree, + webpackConfigPath: string, + devServerOptions: ExtractedOptions, + context: TransformerContext +): void { + let sourceFile: ts.SourceFile; + let webpackConfigText: string; + + const updateSources = () => { + webpackConfigText = tree.read(webpackConfigPath, 'utf-8'); + sourceFile = tsquery.ast(webpackConfigText); + }; + updateSources(); + + setOptionsInWebpackConfig( + tree, + webpackConfigText, + sourceFile, + webpackConfigPath, + devServerOptions + ); + updateSources(); + + setDevServerOptionsInWebpackConfig( + tree, + webpackConfigText, + sourceFile, + webpackConfigPath, + context + ); +} + +function setOptionsInWebpackConfig( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfigPath: string, + devServerOptions: ExtractedOptions +) { + const { default: defaultOptions, ...configurationOptions } = devServerOptions; + + const configValuesSelector = + 'VariableDeclaration:has(Identifier[name=configValues]) ObjectLiteralExpression'; + const configValuesObject = tsquery( + sourceFile, + configValuesSelector + )[0]; + + // configValues must exist at this point, we added it when processing the build target + + /** + * const configValues = { + * ... + * serve: { + * default: { ... }, + * configuration1: { ... }, + * ... + * }, + */ + const updatedConfigValuesObject = ts.factory.updateObjectLiteralExpression( + configValuesObject, + [ + ...configValuesObject.properties, + ts.factory.createPropertyAssignment( + 'serve', + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + 'default', + ts.factory.createObjectLiteralExpression( + Object.entries(defaultOptions).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ), + ...(configurationOptions + ? Object.entries(configurationOptions).map(([key, value]) => + ts.factory.createPropertyAssignment( + key, + ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ) + ) + : []), + ]) + ), + ] + ); + + text = `${text.slice(0, configValuesObject.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedConfigValuesObject, + sourceFile + )}${text.slice(configValuesObject.getEnd())}`; + + tree.write(webpackConfigPath, text); + + sourceFile = tsquery.ast(text); + const buildOptionsSelector = + 'VariableStatement:has(VariableDeclaration:has(Identifier[name=buildOptions]))'; + const buildOptionsStatement = tsquery( + sourceFile, + buildOptionsSelector + )[0]; + + text = `${text.slice(0, buildOptionsStatement.getEnd())} + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + };${text.slice(buildOptionsStatement.getEnd())}`; + + tree.write(webpackConfigPath, text); +} + +function setDevServerOptionsInWebpackConfig( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfigPath: string, + context: TransformerContext +) { + const webpackConfigDevServerSelector = + 'ObjectLiteralExpression > PropertyAssignment:has(Identifier[name=devServer])'; + const webpackConfigDevServer = tsquery( + sourceFile, + webpackConfigDevServerSelector + )[0]; + if (webpackConfigDevServer) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: `The "devServer" option is already set in the webpack config. The migration doesn't support updating it. Please review it and make any necessary changes manually.`, + project: context.projectName, + }); + + text = `${text.slice( + 0, + webpackConfigDevServer.getStart() + )}// This is the untouched "devServer" option from the original webpack config. Please review it and make any necessary changes manually. + ${text.slice(webpackConfigDevServer.getStart())}`; + + tree.write(webpackConfigPath, text); + + // If the devServer property already exists, we don't know how to merge the + // options, so we leave it as is. + return; + } + + const webpackConfigSelector = + 'ObjectLiteralExpression:has(PropertyAssignment:has(Identifier[name=plugins]))'; + const webpackConfig = tsquery( + sourceFile, + webpackConfigSelector + )[0]; + + const updatedWebpackConfig = ts.factory.updateObjectLiteralExpression( + webpackConfig, + [ + ts.factory.createPropertyAssignment( + 'devServer', + ts.factory.createIdentifier('devServerOptions') + ), + ...webpackConfig.properties, + ] + ); + + text = `${text.slice(0, webpackConfig.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedWebpackConfig, + sourceFile + )}${text.slice(webpackConfig.getEnd())}`; + + tree.write(webpackConfigPath, text); +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/types.ts b/packages/webpack/src/generators/convert-to-inferred/utils/types.ts new file mode 100644 index 00000000000000..85b5c1646f04d5 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/types.ts @@ -0,0 +1,13 @@ +import type { ProjectGraph } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +export type MigrationContext = { + logger: AggregatedLog; + projectGraph: ProjectGraph; + workspaceRoot: string; +}; + +export type TransformerContext = MigrationContext & { + projectName: string; + projectRoot: string; +}; diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts index 2fb39b50224576..7d2ba347d89d66 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts @@ -100,7 +100,11 @@ function applyNxIndependentConfig( path: config.output?.path ?? (options.outputPath - ? path.join(options.root, options.outputPath) + ? // If path is relative, it is relative from project root (aka cwd). + // Otherwise, it is relative to workspace root (legacy behavior). + options.outputPath.startsWith('.') + ? path.join(options.root, options.projectRoot, options.outputPath) + : path.join(options.root, options.outputPath) : undefined), filename: config.output?.filename ?? From 8872ca5c8f2f1069ff4ea48238fe821c6f2dc1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 25 Jun 2024 10:37:31 +0200 Subject: [PATCH 10/10] fix(angular): fix chalk import and correctly skip invalid projects in ng-add generator (#26667) ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes #26654 --- .../ng-add/migrators/projects/app.migrator.spec.ts | 12 ++++++++++++ .../ng-add/migrators/projects/app.migrator.ts | 4 ++-- .../ng-add/migrators/projects/lib.migrator.spec.ts | 12 ++++++++++++ .../ng-add/migrators/projects/lib.migrator.ts | 4 ++-- .../ng-add/utilities/validation-logging.spec.ts | 2 +- .../ng-add/utilities/validation-logging.ts | 2 +- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts b/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts index 9be7711bf1a660..074795923e044e 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts +++ b/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts @@ -52,6 +52,18 @@ describe('app migrator', () => { jest.clearAllMocks(); }); + it('should not migrate project when validation fails', async () => { + // add project with no root + const project = addProject('app1', {} as any); + const migrator = new AppMigrator(tree, {}, project); + + await migrator.migrate(); + + expect(tree.exists('apps/app1/project.json')).toBe(false); + const { projects } = readJson(tree, 'angular.json'); + expect(projects.app1).toBeDefined(); + }); + describe('validation', () => { it('should fail validation when the project root is not specified', async () => { const project = addProject('app1', {} as any); diff --git a/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.ts b/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.ts index b96acdc9c974bb..20328c4086b54f 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.ts +++ b/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.ts @@ -103,12 +103,12 @@ export class AppMigrator extends ProjectMigrator { } override async migrate(): Promise { + await super.migrate(); + if (this.skipMigration === true) { return; } - await super.migrate(); - this.updateProjectConfiguration(); await this.e2eMigrator.migrate(); diff --git a/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts b/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts index 64cfa4278d7460..6e2b5c37ce5df5 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts +++ b/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts @@ -50,6 +50,18 @@ describe('lib migrator', () => { jest.clearAllMocks(); }); + it('should not migrate project when validation fails', async () => { + // add project with no root + const project = addProject('lib1', {} as any); + const migrator = new LibMigrator(tree, {}, project); + + await migrator.migrate(); + + expect(tree.exists('libs/lib1/project.json')).toBe(false); + const { projects } = readJson(tree, 'angular.json'); + expect(projects.lib1).toBeDefined(); + }); + describe('validation', () => { it('should fail validation when the project root is not specified', async () => { const project = addProject('lib1', {} as any); diff --git a/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.ts b/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.ts index 7a235536640439..d415d0cdf6a506 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.ts +++ b/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.ts @@ -41,12 +41,12 @@ export class LibMigrator extends ProjectMigrator { } override async migrate(): Promise { + await super.migrate(); + if (this.skipMigration === true) { return; } - await super.migrate(); - await this.updateProjectConfiguration(); this.moveProjectFiles(); diff --git a/packages/angular/src/generators/ng-add/utilities/validation-logging.spec.ts b/packages/angular/src/generators/ng-add/utilities/validation-logging.spec.ts index a78e98454afebb..709a243163fef4 100644 --- a/packages/angular/src/generators/ng-add/utilities/validation-logging.spec.ts +++ b/packages/angular/src/generators/ng-add/utilities/validation-logging.spec.ts @@ -1,4 +1,4 @@ -import * as chalk from 'chalk'; +import chalk = require('chalk'); import { arrayToString, getProjectValidationResultMessage, diff --git a/packages/angular/src/generators/ng-add/utilities/validation-logging.ts b/packages/angular/src/generators/ng-add/utilities/validation-logging.ts index a60f7b86ec2e48..1699edd39802dd 100644 --- a/packages/angular/src/generators/ng-add/utilities/validation-logging.ts +++ b/packages/angular/src/generators/ng-add/utilities/validation-logging.ts @@ -1,4 +1,4 @@ -import * as chalk from 'chalk'; +import chalk = require('chalk'); import type { ValidationError } from './types'; export function arrayToString(array: string[]): string {