From c2920a207a37f927b42615646d740230737359b4 Mon Sep 17 00:00:00 2001 From: James Henry Date: Wed, 7 Aug 2024 18:56:49 +0400 Subject: [PATCH 1/6] feat(release): dynamic release config via programmatic API (#27204) --- docs/generated/cli/release.md | 6 + .../packages/nx/documents/release.md | 6 + packages/nx/release/index.ts | 1 + .../nx/src/command-line/release/changelog.ts | 1026 +++++++++-------- .../command-line/release/command-object.ts | 16 + .../release/config/deep-merge-json.spec.ts | 55 + .../release/config/deep-merge-json.ts | 28 + packages/nx/src/command-line/release/index.ts | 32 +- packages/nx/src/command-line/release/plan.ts | 244 ++-- .../nx/src/command-line/release/publish.ts | 182 +-- .../nx/src/command-line/release/release.ts | 479 ++++---- .../release/utils/print-config.ts | 54 + .../nx/src/command-line/release/version.ts | 506 ++++---- packages/nx/src/config/nx-json.ts | 2 +- 14 files changed, 1476 insertions(+), 1161 deletions(-) create mode 100644 packages/nx/src/command-line/release/config/deep-merge-json.spec.ts create mode 100644 packages/nx/src/command-line/release/config/deep-merge-json.ts create mode 100644 packages/nx/src/command-line/release/utils/print-config.ts diff --git a/docs/generated/cli/release.md b/docs/generated/cli/release.md index d034dc0a2e73b..ff47254e838d8 100644 --- a/docs/generated/cli/release.md +++ b/docs/generated/cli/release.md @@ -37,6 +37,12 @@ Type: `boolean` Show help +### printConfig + +Type: `string` + +Print the resolved nx release configuration that would be used for the current command and then exit + ### projects Type: `string` diff --git a/docs/generated/packages/nx/documents/release.md b/docs/generated/packages/nx/documents/release.md index d034dc0a2e73b..ff47254e838d8 100644 --- a/docs/generated/packages/nx/documents/release.md +++ b/docs/generated/packages/nx/documents/release.md @@ -37,6 +37,12 @@ Type: `boolean` Show help +### printConfig + +Type: `string` + +Print the resolved nx release configuration that would be used for the current command and then exit + ### projects Type: `string` diff --git a/packages/nx/release/index.ts b/packages/nx/release/index.ts index b449fbbf8defc..43a3c6072e3e6 100644 --- a/packages/nx/release/index.ts +++ b/packages/nx/release/index.ts @@ -2,6 +2,7 @@ * @public Programmatic API for nx release */ export { + ReleaseClient, release, releaseChangelog, releasePublish, diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 967b0e162a6f5..e2c79396bec92 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -7,6 +7,7 @@ import { dirSync } from 'tmp'; import type { DependencyBump } from '../../../release/changelog-renderer'; import { NxReleaseChangelogConfiguration, + NxReleaseConfiguration, readNxJson, } from '../../config/nx-json'; import { @@ -33,6 +34,7 @@ import { createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { ReleaseGroupWithName, filterReleaseGroups, @@ -60,6 +62,7 @@ import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github'; import { launchEditor } from './utils/launch-editor'; import { parseChangelogMarkdown } from './utils/markdown'; import { printAndFlushChanges } from './utils/print-changes'; +import { printConfigAndExit } from './utils/print-config'; import { resolveChangelogRenderer } from './utils/resolve-changelog-renderer'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { @@ -101,373 +104,541 @@ export interface ChangelogChange { type PostGitTask = (latestCommit: string) => Promise; export const releaseChangelogCLIHandler = (args: ChangelogOptions) => - handleErrors(args.verbose, () => releaseChangelog(args)); - -/** - * NOTE: This function is also exported for programmatic usage and forms part of the public API - * of Nx. We intentionally do not wrap the implementation with handleErrors because users need - * to have control over their own error handling when using the API. - */ -export async function releaseChangelog( - args: ChangelogOptions -): Promise { - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const nxJson = readNxJson(); - - if (args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } - - // Apply default configuration to any optional user configuration - const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( - projectGraph, - await createProjectFileMapUsingProjectGraph(projectGraph), - nxJson.release - ); - if (configError) { - return await handleNxReleaseConfigError(configError); - } + handleErrors(args.verbose, () => createAPI({})(args)); - // The nx release top level command will always override these three git args. This is how we can tell - // if the top level release command was used or if the user is using the changelog subcommand. - // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, - // as all of the git options would be overridden anyway. - if ( - (args.gitCommit === undefined || - args.gitTag === undefined || - args.stageChanges === undefined) && - nxJson.release?.git - ) { - const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ - 'release', - 'git', - ]); - output.error({ - title: `The "release.git" property in nx.json may not be used with the "nx release changelog" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, - bodyLines: [nxJsonMessage], - }); - process.exit(1); - } - - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - args.projects, - args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } - const rawVersionPlans = await readRawVersionPlans(); - setVersionPlansOnGroups( - rawVersionPlans, - releaseGroups, - Object.keys(projectGraph.nodes) - ); - - if (args.deleteVersionPlans === undefined) { - // default to deleting version plans in this command instead of after versioning - args.deleteVersionPlans = true; - } +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + /** + * NOTE: This function is also exported for programmatic usage and forms part of the public API + * of Nx. We intentionally do not wrap the implementation with handleErrors because users need + * to have control over their own error handling when using the API. + */ + return async function releaseChangelog( + args: ChangelogOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - const changelogGenerationEnabled = - !!nxReleaseConfig.changelog.workspaceChangelog || - Object.values(nxReleaseConfig.groups).some((g) => g.changelog); - if (!changelogGenerationEnabled) { - output.warn({ - title: `Changelogs are disabled. No changelog entries will be generated`, - bodyLines: [ - `To explicitly enable changelog generation, configure "release.changelog.workspaceChangelog" or "release.changelog.projectChangelogs" in nx.json.`, - ], - }); - return {}; - } + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } - const tree = new FsTree(workspaceRoot, args.verbose); + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + userProvidedReleaseConfig + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', + }); + } - const useAutomaticFromRef = - nxReleaseConfig.changelog?.automaticFromRef || args.firstRelease; + // The nx release top level command will always override these three git args. This is how we can tell + // if the top level release command was used or if the user is using the changelog subcommand. + // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, + // as all of the git options would be overridden anyway. + if ( + (args.gitCommit === undefined || + args.gitTag === undefined || + args.stageChanges === undefined) && + userProvidedReleaseConfig.git + ) { + const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ + 'release', + 'git', + ]); + output.error({ + title: `The "release.git" property in nx.json may not be used with the "nx release changelog" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, + bodyLines: [nxJsonMessage], + }); + process.exit(1); + } - /** - * For determining the versions to use within changelog files, there are a few different possibilities: - * - the user is using the nx CLI, and therefore passes a single --version argument which represents the version for any and all changelog - * files which will be generated (i.e. both the workspace changelog, and all project changelogs, depending on which of those has been enabled) - * - the user is using the nxReleaseChangelog API programmatically, and: - * - passes only a version property - * - this works in the same way as described above for the CLI - * - passes only a versionData object - * - this is a special case where the user is providing a version for each project, and therefore the version argument is not needed - * - NOTE: it is not possible to generate a workspace level changelog with only a versionData object, and this will produce an error - * - passes both a version and a versionData object - * - in this case, the version property will be used as the reference for the workspace changelog, and the versionData object will be used - * to generate project changelogs - */ - const { workspaceChangelogVersion, projectsVersionData } = - resolveChangelogVersions( - args, + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, releaseGroups, - releaseGroupToFilteredProjects + Object.keys(projectGraph.nodes) ); - const to = args.to || 'HEAD'; - const toSHA = await getCommitHash(to); - const headSHA = to === 'HEAD' ? toSHA : await getCommitHash('HEAD'); + if (args.deleteVersionPlans === undefined) { + // default to deleting version plans in this command instead of after versioning + args.deleteVersionPlans = true; + } - /** - * Protect the user against attempting to create a new commit when recreating an old release changelog, - * this seems like it would always be unintentional. - */ - const autoCommitEnabled = - args.gitCommit ?? nxReleaseConfig.changelog.git.commit; - if (autoCommitEnabled && headSHA !== toSHA) { - throw new Error( - `You are attempting to recreate the changelog for an old release, but you have enabled auto-commit mode. Please disable auto-commit mode by updating your nx.json, or passing --git-commit=false` - ); - } + const changelogGenerationEnabled = + !!nxReleaseConfig.changelog.workspaceChangelog || + Object.values(nxReleaseConfig.groups).some((g) => g.changelog); + if (!changelogGenerationEnabled) { + output.warn({ + title: `Changelogs are disabled. No changelog entries will be generated`, + bodyLines: [ + `To explicitly enable changelog generation, configure "release.changelog.workspaceChangelog" or "release.changelog.projectChangelogs" in nx.json.`, + ], + }); + return {}; + } - const commitMessage: string | undefined = - args.gitCommitMessage || nxReleaseConfig.changelog.git.commitMessage; + const tree = new FsTree(workspaceRoot, args.verbose); - const commitMessageValues: string[] = createCommitMessageValues( - releaseGroups, - releaseGroupToFilteredProjects, - projectsVersionData, - commitMessage - ); + const useAutomaticFromRef = + nxReleaseConfig.changelog?.automaticFromRef || args.firstRelease; - // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command - const gitTagValues: string[] = - args.gitTag ?? nxReleaseConfig.changelog.git.tag - ? createGitTagValues( - releaseGroups, - releaseGroupToFilteredProjects, - projectsVersionData - ) - : []; - handleDuplicateGitTags(gitTagValues); - - const postGitTasks: PostGitTask[] = []; - - let workspaceChangelogChanges: ChangelogChange[] = []; - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - let workspaceChangelogCommits: GitCommit[] = []; - - // If there are multiple release groups, we'll just skip the workspace changelog anyway. - const versionPlansEnabledForWorkspaceChangelog = - releaseGroups[0].versionPlans; - if (versionPlansEnabledForWorkspaceChangelog) { - if (releaseGroups.length === 1) { - const releaseGroup = releaseGroups[0]; - if (releaseGroup.projectsRelationship === 'fixed') { - const versionPlans = releaseGroup.versionPlans as GroupVersionPlan[]; - workspaceChangelogChanges = filterHiddenChanges( - versionPlans - .map((vp) => { - const parsedMessage = parseConventionalCommitsMessage(vp.message); - - // only properly formatted conventional commits messages will be included in the changelog - if (!parsedMessage) { - return null; - } + /** + * For determining the versions to use within changelog files, there are a few different possibilities: + * - the user is using the nx CLI, and therefore passes a single --version argument which represents the version for any and all changelog + * files which will be generated (i.e. both the workspace changelog, and all project changelogs, depending on which of those has been enabled) + * - the user is using the nxReleaseChangelog API programmatically, and: + * - passes only a version property + * - this works in the same way as described above for the CLI + * - passes only a versionData object + * - this is a special case where the user is providing a version for each project, and therefore the version argument is not needed + * - NOTE: it is not possible to generate a workspace level changelog with only a versionData object, and this will produce an error + * - passes both a version and a versionData object + * - in this case, the version property will be used as the reference for the workspace changelog, and the versionData object will be used + * to generate project changelogs + */ + const { workspaceChangelogVersion, projectsVersionData } = + resolveChangelogVersions( + args, + releaseGroups, + releaseGroupToFilteredProjects + ); - return { - type: parsedMessage.type, - scope: parsedMessage.scope, - description: parsedMessage.description, - body: '', - isBreaking: parsedMessage.breaking, - githubReferences: [], - }; - }) - .filter(Boolean), - nxReleaseConfig.conventionalCommits - ); - } - } - } else { - let workspaceChangelogFromRef = - args.from || - (await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern))?.tag; - if (!workspaceChangelogFromRef) { - if (useAutomaticFromRef) { - workspaceChangelogFromRef = await getFirstGitCommit(); - if (args.verbose) { - console.log( - `Determined workspace --from ref from the first commit in the workspace: ${workspaceChangelogFromRef}` - ); - } - } else { - throw new Error( - `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` - ); - } + const to = args.to || 'HEAD'; + const toSHA = await getCommitHash(to); + const headSHA = to === 'HEAD' ? toSHA : await getCommitHash('HEAD'); + + /** + * Protect the user against attempting to create a new commit when recreating an old release changelog, + * this seems like it would always be unintentional. + */ + const autoCommitEnabled = + args.gitCommit ?? nxReleaseConfig.changelog.git.commit; + if (autoCommitEnabled && headSHA !== toSHA) { + throw new Error( + `You are attempting to recreate the changelog for an old release, but you have enabled auto-commit mode. Please disable auto-commit mode by updating your nx.json, or passing --git-commit=false` + ); } - // Make sure that the fromRef is actually resolvable - const workspaceChangelogFromSHA = await getCommitHash( - workspaceChangelogFromRef - ); + const commitMessage: string | undefined = + args.gitCommitMessage || nxReleaseConfig.changelog.git.commitMessage; - workspaceChangelogCommits = await getCommits( - workspaceChangelogFromSHA, - toSHA + const commitMessageValues: string[] = createCommitMessageValues( + releaseGroups, + releaseGroupToFilteredProjects, + projectsVersionData, + commitMessage ); - workspaceChangelogChanges = filterHiddenChanges( - workspaceChangelogCommits.map((c) => { - return { - type: c.type, - scope: c.scope, - description: c.description, - body: c.body, - isBreaking: c.isBreaking, - githubReferences: c.references, - author: c.author, - shortHash: c.shortHash, - revertedHashes: c.revertedHashes, - affectedProjects: '*', - }; - }), - nxReleaseConfig.conventionalCommits - ); - } + // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command + const gitTagValues: string[] = + args.gitTag ?? nxReleaseConfig.changelog.git.tag + ? createGitTagValues( + releaseGroups, + releaseGroupToFilteredProjects, + projectsVersionData + ) + : []; + handleDuplicateGitTags(gitTagValues); - const workspaceChangelog = await generateChangelogForWorkspace({ - tree, - args, - projectGraph, - nxReleaseConfig, - workspaceChangelogVersion, - changes: workspaceChangelogChanges, - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - commits: filterHiddenCommits( - workspaceChangelogCommits, - nxReleaseConfig.conventionalCommits - ), - }); + const postGitTasks: PostGitTask[] = []; - if ( - workspaceChangelog && - shouldCreateGitHubRelease( - nxReleaseConfig.changelog.workspaceChangelog, - args.createRelease - ) - ) { - let hasPushed = false; + let workspaceChangelogChanges: ChangelogChange[] = []; + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + let workspaceChangelogCommits: GitCommit[] = []; + + // If there are multiple release groups, we'll just skip the workspace changelog anyway. + const versionPlansEnabledForWorkspaceChangelog = + releaseGroups[0].versionPlans; + if (versionPlansEnabledForWorkspaceChangelog) { + if (releaseGroups.length === 1) { + const releaseGroup = releaseGroups[0]; + if (releaseGroup.projectsRelationship === 'fixed') { + const versionPlans = releaseGroup.versionPlans as GroupVersionPlan[]; + workspaceChangelogChanges = filterHiddenChanges( + versionPlans + .map((vp) => { + const parsedMessage = parseConventionalCommitsMessage( + vp.message + ); - postGitTasks.push(async (latestCommit) => { - if (!hasPushed) { - output.logSingleLine(`Pushing to git remote`); + // only properly formatted conventional commits messages will be included in the changelog + if (!parsedMessage) { + return null; + } - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - gitRemote: args.gitRemote, - dryRun: args.dryRun, - verbose: args.verbose, - }); - hasPushed = true; + return { + type: parsedMessage.type, + scope: parsedMessage.scope, + description: parsedMessage.description, + body: '', + isBreaking: parsedMessage.breaking, + githubReferences: [], + }; + }) + .filter(Boolean), + nxReleaseConfig.conventionalCommits + ); + } + } + } else { + let workspaceChangelogFromRef = + args.from || + (await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern)) + ?.tag; + if (!workspaceChangelogFromRef) { + if (useAutomaticFromRef) { + workspaceChangelogFromRef = await getFirstGitCommit(); + if (args.verbose) { + console.log( + `Determined workspace --from ref from the first commit in the workspace: ${workspaceChangelogFromRef}` + ); + } + } else { + throw new Error( + `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` + ); + } } - output.logSingleLine(`Creating GitHub Release`); + // Make sure that the fromRef is actually resolvable + const workspaceChangelogFromSHA = await getCommitHash( + workspaceChangelogFromRef + ); + + workspaceChangelogCommits = await getCommits( + workspaceChangelogFromSHA, + toSHA + ); - await createOrUpdateGithubRelease( - workspaceChangelog.releaseVersion, - workspaceChangelog.contents, - latestCommit, - { dryRun: args.dryRun } + workspaceChangelogChanges = filterHiddenChanges( + workspaceChangelogCommits.map((c) => { + return { + type: c.type, + scope: c.scope, + description: c.description, + body: c.body, + isBreaking: c.isBreaking, + githubReferences: c.references, + author: c.author, + shortHash: c.shortHash, + revertedHashes: c.revertedHashes, + affectedProjects: '*', + }; + }), + nxReleaseConfig.conventionalCommits ); + } + + const workspaceChangelog = await generateChangelogForWorkspace({ + tree, + args, + projectGraph, + nxReleaseConfig, + workspaceChangelogVersion, + changes: workspaceChangelogChanges, + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + commits: filterHiddenCommits( + workspaceChangelogCommits, + nxReleaseConfig.conventionalCommits + ), }); - } - /** - * Compute any additional dependency bumps up front because there could be cases of circular dependencies, - * and figuring them out during the main iteration would be too late. - */ - const projectToAdditionalDependencyBumps = new Map< - string, - DependencyBump[] - >(); - for (const releaseGroup of releaseGroups) { - if (releaseGroup.projectsRelationship !== 'independent') { - continue; + if ( + workspaceChangelog && + shouldCreateGitHubRelease( + nxReleaseConfig.changelog.workspaceChangelog, + args.createRelease + ) + ) { + let hasPushed = false; + + postGitTasks.push(async (latestCommit) => { + if (!hasPushed) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + gitRemote: args.gitRemote, + dryRun: args.dryRun, + verbose: args.verbose, + }); + hasPushed = true; + } + + output.logSingleLine(`Creating GitHub Release`); + + await createOrUpdateGithubRelease( + workspaceChangelog.releaseVersion, + workspaceChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + }); } - for (const project of releaseGroup.projects) { - // If the project does not have any changes, do not process its dependents - if ( - !projectsVersionData[project] || - projectsVersionData[project].newVersion === null - ) { + + /** + * Compute any additional dependency bumps up front because there could be cases of circular dependencies, + * and figuring them out during the main iteration would be too late. + */ + const projectToAdditionalDependencyBumps = new Map< + string, + DependencyBump[] + >(); + for (const releaseGroup of releaseGroups) { + if (releaseGroup.projectsRelationship !== 'independent') { continue; } + for (const project of releaseGroup.projects) { + // If the project does not have any changes, do not process its dependents + if ( + !projectsVersionData[project] || + projectsVersionData[project].newVersion === null + ) { + continue; + } - const dependentProjects = ( - projectsVersionData[project].dependentProjects || [] - ) - .map((dep) => { - return { - dependencyName: dep.source, - newVersion: projectsVersionData[dep.source].newVersion, - }; - }) - .filter((b) => b.newVersion !== null); - - for (const dependent of dependentProjects) { - const additionalDependencyBumpsForProject = - projectToAdditionalDependencyBumps.has(dependent.dependencyName) - ? projectToAdditionalDependencyBumps.get(dependent.dependencyName) - : []; - additionalDependencyBumpsForProject.push({ - dependencyName: project, - newVersion: projectsVersionData[project].newVersion, - }); - projectToAdditionalDependencyBumps.set( - dependent.dependencyName, - additionalDependencyBumpsForProject - ); + const dependentProjects = ( + projectsVersionData[project].dependentProjects || [] + ) + .map((dep) => { + return { + dependencyName: dep.source, + newVersion: projectsVersionData[dep.source].newVersion, + }; + }) + .filter((b) => b.newVersion !== null); + + for (const dependent of dependentProjects) { + const additionalDependencyBumpsForProject = + projectToAdditionalDependencyBumps.has(dependent.dependencyName) + ? projectToAdditionalDependencyBumps.get(dependent.dependencyName) + : []; + additionalDependencyBumpsForProject.push({ + dependencyName: project, + newVersion: projectsVersionData[project].newVersion, + }); + projectToAdditionalDependencyBumps.set( + dependent.dependencyName, + additionalDependencyBumpsForProject + ); + } } } - } - const allProjectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = - {}; + const allProjectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = + {}; - for (const releaseGroup of releaseGroups) { - const config = releaseGroup.changelog; - // The entire feature is disabled at the release group level, exit early - if (config === false) { - continue; - } + for (const releaseGroup of releaseGroups) { + const config = releaseGroup.changelog; + // The entire feature is disabled at the release group level, exit early + if (config === false) { + continue; + } + + const projects = args.projects?.length + ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group, plus any dependents + Array.from(releaseGroupToFilteredProjects.get(releaseGroup)).flatMap( + (project) => { + return [ + project, + ...(projectsVersionData[project]?.dependentProjects.map( + (dep) => dep.source + ) || []), + ]; + } + ) + : // Otherwise, we use the full list of projects within the release group + releaseGroup.projects; + const projectNodes = projects.map((name) => projectGraph.nodes[name]); + + if (releaseGroup.projectsRelationship === 'independent') { + for (const project of projectNodes) { + let changes: ChangelogChange[] | null = null; + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + let commits: GitCommit[]; + + if (releaseGroup.versionPlans) { + changes = filterHiddenChanges( + (releaseGroup.versionPlans as ProjectsVersionPlan[]) + .map((vp) => { + const parsedMessage = parseConventionalCommitsMessage( + vp.message + ); + + // only properly formatted conventional commits messages will be included in the changelog + if (!parsedMessage) { + return null; + } + + return { + type: parsedMessage.type, + scope: parsedMessage.scope, + description: parsedMessage.description, + body: '', + isBreaking: parsedMessage.breaking, + affectedProjects: Object.keys(vp.projectVersionBumps), + githubReferences: [], + }; + }) + .filter(Boolean), + nxReleaseConfig.conventionalCommits + ); + } else { + let fromRef = + args.from || + ( + await getLatestGitTagForPattern( + releaseGroup.releaseTagPattern, + { + projectName: project.name, + releaseGroupName: releaseGroup.name, + } + ) + )?.tag; + + if (!fromRef && useAutomaticFromRef) { + const firstCommit = await getFirstGitCommit(); + const allCommits = await getCommits(firstCommit, toSHA); + const commitsForProject = allCommits.filter((c) => + c.affectedFiles.find((f) => f.startsWith(project.data.root)) + ); + + fromRef = commitsForProject[0]?.shortHash; + if (args.verbose) { + console.log( + `Determined --from ref for ${project.name} from the first commit in which it exists: ${fromRef}` + ); + } + commits = commitsForProject; + } + + if (!fromRef && !commits) { + throw new Error( + `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` + ); + } + + if (!commits) { + commits = await getCommits(fromRef, toSHA); + } - const projects = args.projects?.length - ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group, plus any dependents - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)).flatMap( - (project) => { - return [ - project, - ...(projectsVersionData[project]?.dependentProjects.map( - (dep) => dep.source - ) || []), - ]; + const { fileMap } = await createFileMapUsingProjectGraph( + projectGraph + ); + const fileToProjectMap = createFileToProjectMap( + fileMap.projectFileMap + ); + + changes = filterHiddenChanges( + commits.map((c) => ({ + type: c.type, + scope: c.scope, + description: c.description, + body: c.body, + isBreaking: c.isBreaking, + githubReferences: c.references, + author: c.author, + shortHash: c.shortHash, + revertedHashes: c.revertedHashes, + affectedProjects: commitChangesNonProjectFiles( + c, + fileMap.nonProjectFiles + ) + ? '*' + : getProjectsAffectedByCommit(c, fileToProjectMap), + })), + nxReleaseConfig.conventionalCommits + ); } - ) - : // Otherwise, we use the full list of projects within the release group - releaseGroup.projects; - const projectNodes = projects.map((name) => projectGraph.nodes[name]); - if (releaseGroup.projectsRelationship === 'independent') { - for (const project of projectNodes) { - let changes: ChangelogChange[] | null = null; - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - let commits: GitCommit[]; + const projectChangelogs = await generateChangelogForProjects({ + tree, + args, + projectGraph, + changes, + projectsVersionData, + releaseGroup, + projects: [project], + nxReleaseConfig, + projectToAdditionalDependencyBumps, + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + commits: filterHiddenCommits( + commits, + nxReleaseConfig.conventionalCommits + ), + }); + + let hasPushed = false; + for (const [projectName, projectChangelog] of Object.entries( + projectChangelogs + )) { + if ( + projectChangelogs && + shouldCreateGitHubRelease( + releaseGroup.changelog, + args.createRelease + ) + ) { + postGitTasks.push(async (latestCommit) => { + if (!hasPushed) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + gitRemote: args.gitRemote, + dryRun: args.dryRun, + verbose: args.verbose, + }); + hasPushed = true; + } + output.logSingleLine(`Creating GitHub Release`); + + await createOrUpdateGithubRelease( + projectChangelog.releaseVersion, + projectChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + }); + } + allProjectChangelogs[projectName] = projectChangelog; + } + } + } else { + let changes: ChangelogChange[] = []; + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + let commits: GitCommit[] = []; if (releaseGroup.versionPlans) { changes = filterHiddenChanges( - (releaseGroup.versionPlans as ProjectsVersionPlan[]) + (releaseGroup.versionPlans as GroupVersionPlan[]) .map((vp) => { const parsedMessage = parseConventionalCommitsMessage( vp.message @@ -478,14 +649,14 @@ export async function releaseChangelog( return null; } - return { + return { type: parsedMessage.type, scope: parsedMessage.scope, description: parsedMessage.description, body: '', isBreaking: parsedMessage.breaking, - affectedProjects: Object.keys(vp.projectVersionBumps), githubReferences: [], + affectedProjects: '*', }; }) .filter(Boolean), @@ -494,38 +665,25 @@ export async function releaseChangelog( } else { let fromRef = args.from || - ( - await getLatestGitTagForPattern(releaseGroup.releaseTagPattern, { - projectName: project.name, - releaseGroupName: releaseGroup.name, - }) - )?.tag; - - if (!fromRef && useAutomaticFromRef) { - const firstCommit = await getFirstGitCommit(); - const allCommits = await getCommits(firstCommit, toSHA); - const commitsForProject = allCommits.filter((c) => - c.affectedFiles.find((f) => f.startsWith(project.data.root)) - ); - - fromRef = commitsForProject[0]?.shortHash; - if (args.verbose) { - console.log( - `Determined --from ref for ${project.name} from the first commit in which it exists: ${fromRef}` + (await getLatestGitTagForPattern(releaseGroup.releaseTagPattern)) + ?.tag; + if (!fromRef) { + if (useAutomaticFromRef) { + fromRef = await getFirstGitCommit(); + if (args.verbose) { + console.log( + `Determined release group --from ref from the first commit in the workspace: ${fromRef}` + ); + } + } else { + throw new Error( + `Unable to determine the previous git tag. If this is the first release of your release group, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` ); } - commits = commitsForProject; } - if (!fromRef && !commits) { - throw new Error( - `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` - ); - } - - if (!commits) { - commits = await getCommits(fromRef, toSHA); - } + // Make sure that the fromRef is actually resolvable + const fromSHA = await getCommitHash(fromRef); const { fileMap } = await createFileMapUsingProjectGraph( projectGraph @@ -534,6 +692,7 @@ export async function releaseChangelog( fileMap.projectFileMap ); + commits = await getCommits(fromSHA, toSHA); changes = filterHiddenChanges( commits.map((c) => ({ type: c.type, @@ -563,7 +722,7 @@ export async function releaseChangelog( changes, projectsVersionData, releaseGroup, - projects: [project], + projects: projectNodes, nxReleaseConfig, projectToAdditionalDependencyBumps, // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits @@ -610,150 +769,23 @@ export async function releaseChangelog( allProjectChangelogs[projectName] = projectChangelog; } } - } else { - let changes: ChangelogChange[] = []; - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - let commits: GitCommit[] = []; - if (releaseGroup.versionPlans) { - changes = filterHiddenChanges( - (releaseGroup.versionPlans as GroupVersionPlan[]) - .map((vp) => { - const parsedMessage = parseConventionalCommitsMessage(vp.message); - - // only properly formatted conventional commits messages will be included in the changelog - if (!parsedMessage) { - return null; - } - - return { - type: parsedMessage.type, - scope: parsedMessage.scope, - description: parsedMessage.description, - body: '', - isBreaking: parsedMessage.breaking, - githubReferences: [], - affectedProjects: '*', - }; - }) - .filter(Boolean), - nxReleaseConfig.conventionalCommits - ); - } else { - let fromRef = - args.from || - (await getLatestGitTagForPattern(releaseGroup.releaseTagPattern)) - ?.tag; - if (!fromRef) { - if (useAutomaticFromRef) { - fromRef = await getFirstGitCommit(); - if (args.verbose) { - console.log( - `Determined release group --from ref from the first commit in the workspace: ${fromRef}` - ); - } - } else { - throw new Error( - `Unable to determine the previous git tag. If this is the first release of your release group, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` - ); - } - } - - // Make sure that the fromRef is actually resolvable - const fromSHA = await getCommitHash(fromRef); - - const { fileMap } = await createFileMapUsingProjectGraph(projectGraph); - const fileToProjectMap = createFileToProjectMap(fileMap.projectFileMap); - - commits = await getCommits(fromSHA, toSHA); - changes = filterHiddenChanges( - commits.map((c) => ({ - type: c.type, - scope: c.scope, - description: c.description, - body: c.body, - isBreaking: c.isBreaking, - githubReferences: c.references, - author: c.author, - shortHash: c.shortHash, - revertedHashes: c.revertedHashes, - affectedProjects: commitChangesNonProjectFiles( - c, - fileMap.nonProjectFiles - ) - ? '*' - : getProjectsAffectedByCommit(c, fileToProjectMap), - })), - nxReleaseConfig.conventionalCommits - ); - } - - const projectChangelogs = await generateChangelogForProjects({ - tree, - args, - projectGraph, - changes, - projectsVersionData, - releaseGroup, - projects: projectNodes, - nxReleaseConfig, - projectToAdditionalDependencyBumps, - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - commits: filterHiddenCommits( - commits, - nxReleaseConfig.conventionalCommits - ), - }); - - let hasPushed = false; - for (const [projectName, projectChangelog] of Object.entries( - projectChangelogs - )) { - if ( - projectChangelogs && - shouldCreateGitHubRelease(releaseGroup.changelog, args.createRelease) - ) { - postGitTasks.push(async (latestCommit) => { - if (!hasPushed) { - output.logSingleLine(`Pushing to git remote`); - - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - gitRemote: args.gitRemote, - dryRun: args.dryRun, - verbose: args.verbose, - }); - hasPushed = true; - } - - output.logSingleLine(`Creating GitHub Release`); - - await createOrUpdateGithubRelease( - projectChangelog.releaseVersion, - projectChangelog.contents, - latestCommit, - { dryRun: args.dryRun } - ); - }); - } - allProjectChangelogs[projectName] = projectChangelog; - } } - } - await applyChangesAndExit( - args, - nxReleaseConfig, - tree, - toSHA, - postGitTasks, - commitMessageValues, - gitTagValues, - releaseGroups - ); + await applyChangesAndExit( + args, + nxReleaseConfig, + tree, + toSHA, + postGitTasks, + commitMessageValues, + gitTagValues, + releaseGroups + ); - return { - workspaceChangelog, - projectChangelogs: allProjectChangelogs, + return { + workspaceChangelog, + projectChangelogs: allProjectChangelogs, + }; }; } diff --git a/packages/nx/src/command-line/release/command-object.ts b/packages/nx/src/command-line/release/command-object.ts index 7a66e50bfc151..46d39bb388182 100644 --- a/packages/nx/src/command-line/release/command-object.ts +++ b/packages/nx/src/command-line/release/command-object.ts @@ -17,6 +17,7 @@ export interface NxReleaseArgs { projects?: string[]; dryRun?: boolean; verbose?: boolean; + printConfig?: boolean | 'debug'; } interface GitCommitAndTagOptions { @@ -122,6 +123,21 @@ export const yargsReleaseCommand: CommandModule< describe: 'Prints additional information about the commands (e.g., stack traces)', }) + // NOTE: The camel case format is required for the coerce() function to be called correctly. It still supports --print-config casing. + .option('printConfig', { + type: 'string', + describe: + 'Print the resolved nx release configuration that would be used for the current command and then exit', + coerce: (val: string) => { + if (val === '') { + return true; + } + if (val === 'false') { + return false; + } + return val; + }, + }) .check((argv) => { if (argv.groups && argv.projects) { throw new Error( diff --git a/packages/nx/src/command-line/release/config/deep-merge-json.spec.ts b/packages/nx/src/command-line/release/config/deep-merge-json.spec.ts new file mode 100644 index 0000000000000..35558b2146f3f --- /dev/null +++ b/packages/nx/src/command-line/release/config/deep-merge-json.spec.ts @@ -0,0 +1,55 @@ +import { deepMergeJson } from './deep-merge-json'; + +describe('deepMergeJson()', () => { + it('should merge two JSON objects', () => { + const target = { + a: 1, + b: { + c: 2, + d: { + e: [1, 2, 3, 4, 5, 6], + f: true, + g: false, + h: '', + i: null, + }, + }, + }; + const source = { + a: 3, + b: { + c: 4, + d: { + e: [4, 5, 6, 7, 8, 9], + f: false, + g: true, + h: null, + i: '', + }, + }, + }; + const result = deepMergeJson(target, source); + expect(result).toMatchInlineSnapshot(` + { + "a": 3, + "b": { + "c": 4, + "d": { + "e": [ + 4, + 5, + 6, + 7, + 8, + 9, + ], + "f": false, + "g": true, + "h": null, + "i": "", + }, + }, + } + `); + }); +}); diff --git a/packages/nx/src/command-line/release/config/deep-merge-json.ts b/packages/nx/src/command-line/release/config/deep-merge-json.ts new file mode 100644 index 0000000000000..6910da1206936 --- /dev/null +++ b/packages/nx/src/command-line/release/config/deep-merge-json.ts @@ -0,0 +1,28 @@ +function isObject(obj: any): obj is Record { + return obj && typeof obj === 'object' && !Array.isArray(obj); +} + +export function deepMergeJson>( + target: T, + source: T +): T { + try { + // Ensure both objects are valid JSON before attempting to merge values + JSON.parse(JSON.stringify(source)); + JSON.parse(JSON.stringify(target)); + + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) { + Object.assign(target, { [key]: {} }); + } + deepMergeJson(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + return target; + } catch { + throw new Error('Invalid JSON was provided'); + } +} diff --git a/packages/nx/src/command-line/release/index.ts b/packages/nx/src/command-line/release/index.ts index 069178938852e..382de80f419c0 100644 --- a/packages/nx/src/command-line/release/index.ts +++ b/packages/nx/src/command-line/release/index.ts @@ -1,16 +1,40 @@ +import type { NxReleaseConfiguration } from '../../config/nx-json'; +import { createAPI as createReleaseChangelogAPI } from './changelog'; +import { createAPI as createReleasePublishAPI } from './publish'; +import { createAPI as createReleaseAPI } from './release'; +import { createAPI as createReleaseVersionAPI } from './version'; + /** * @public */ -export { releaseChangelog } from './changelog'; +export class ReleaseClient { + releaseChangelog = createReleaseChangelogAPI(this.overrideReleaseConfig); + releasePublish = createReleasePublishAPI(this.overrideReleaseConfig); + releaseVersion = createReleaseVersionAPI(this.overrideReleaseConfig); + release = createReleaseAPI(this.overrideReleaseConfig); + + constructor(private overrideReleaseConfig: NxReleaseConfiguration) {} +} + +const defaultClient = new ReleaseClient({}); + /** * @public */ -export { releasePublish } from './publish'; +export const releaseChangelog = + defaultClient.releaseChangelog.bind(defaultClient); + /** * @public */ -export { releaseVersion } from './version'; +export const releasePublish = defaultClient.releasePublish.bind(defaultClient); + /** * @public */ -export { release } from './release'; +export const releaseVersion = defaultClient.releaseVersion.bind(defaultClient); + +/** + * @public + */ +export const release = defaultClient.release.bind(defaultClient); diff --git a/packages/nx/src/command-line/release/plan.ts b/packages/nx/src/command-line/release/plan.ts index b8dc176fa38ca..3b12c73c0dec8 100644 --- a/packages/nx/src/command-line/release/plan.ts +++ b/packages/nx/src/command-line/release/plan.ts @@ -2,7 +2,7 @@ import { prompt } from 'enquirer'; import { ensureDir, writeFile } from 'fs-extra'; import { join } from 'path'; import { RELEASE_TYPES } from 'semver'; -import { readNxJson } from '../../config/nx-json'; +import { NxReleaseConfiguration, readNxJson } from '../../config/nx-json'; import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils'; import { createProjectGraphAsync } from '../../project-graph/project-graph'; import { output } from '../../utils/output'; @@ -18,149 +18,169 @@ import { getVersionPlansAbsolutePath } from './config/version-plans'; import { generateVersionPlanContent } from './utils/generate-version-plan-content'; import { parseConventionalCommitsMessage } from './utils/git'; import { printDiff } from './utils/print-changes'; +import { printConfigAndExit } from './utils/print-config'; +import { deepMergeJson } from './config/deep-merge-json'; export const releasePlanCLIHandler = (args: PlanOptions) => - handleErrors(args.verbose, () => releasePlan(args)); - -export async function releasePlan(args: PlanOptions): Promise { - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const nxJson = readNxJson(); - - if (args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } - - // Apply default configuration to any optional user configuration - const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( - projectGraph, - await createProjectFileMapUsingProjectGraph(projectGraph), - nxJson.release - ); - if (configError) { - return await handleNxReleaseConfigError(configError); - } - - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - args.projects, - args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } + handleErrors(args.verbose, () => createAPI({})(args)); + +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + return async function releasePlan( + args: PlanOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - const versionPlanBumps: Record = {}; - const setBumpIfNotNone = (projectOrGroup: string, version: string) => { - if (version !== 'none') { - versionPlanBumps[projectOrGroup] = version; + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; } - }; - if (args.message) { - const message = parseConventionalCommitsMessage(args.message); - if (!message) { - output.error({ - title: 'Changelog message is not in conventional commits format.', - bodyLines: [ - 'Please ensure your message is in the form of:', - ' type(optional scope): description', - '', - 'For example:', - ' feat(pkg-b): add new feature', - ' fix(pkg-a): correct a bug', - ' chore: update build process', - ' fix(core)!: breaking change in core package', - ], + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + nxJson.release + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', }); + } + + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); process.exit(1); } - } - if (releaseGroups[0].name === IMPLICIT_DEFAULT_RELEASE_GROUP) { - const group = releaseGroups[0]; - if (group.projectsRelationship === 'independent') { - for (const project of group.projects) { - setBumpIfNotNone( - project, - args.bump || - (await promptForVersion( - `How do you want to bump the version of the project "${project}"?` - )) - ); + const versionPlanBumps: Record = {}; + const setBumpIfNotNone = (projectOrGroup: string, version: string) => { + if (version !== 'none') { + versionPlanBumps[projectOrGroup] = version; + } + }; + + if (args.message) { + const message = parseConventionalCommitsMessage(args.message); + if (!message) { + output.error({ + title: 'Changelog message is not in conventional commits format.', + bodyLines: [ + 'Please ensure your message is in the form of:', + ' type(optional scope): description', + '', + 'For example:', + ' feat(pkg-b): add new feature', + ' fix(pkg-a): correct a bug', + ' chore: update build process', + ' fix(core)!: breaking change in core package', + ], + }); + process.exit(1); } - } else { - // TODO: use project names instead of the implicit default release group name? (though this might be confusing, as users might think they can just delete one of the project bumps to change the behavior to independent versioning) - setBumpIfNotNone( - group.name, - args.bump || - (await promptForVersion( - `How do you want to bump the versions of all projects?` - )) - ); } - } else { - for (const group of releaseGroups) { + + if (releaseGroups[0].name === IMPLICIT_DEFAULT_RELEASE_GROUP) { + const group = releaseGroups[0]; if (group.projectsRelationship === 'independent') { - for (const project of releaseGroupToFilteredProjects.get(group)) { + for (const project of group.projects) { setBumpIfNotNone( project, args.bump || (await promptForVersion( - `How do you want to bump the version of the project "${project}" within group "${group.name}"?` + `How do you want to bump the version of the project "${project}"?` )) ); } } else { + // TODO: use project names instead of the implicit default release group name? (though this might be confusing, as users might think they can just delete one of the project bumps to change the behavior to independent versioning) setBumpIfNotNone( group.name, args.bump || (await promptForVersion( - `How do you want to bump the versions of the projects in the group "${group.name}"?` + `How do you want to bump the versions of all projects?` )) ); } + } else { + for (const group of releaseGroups) { + if (group.projectsRelationship === 'independent') { + for (const project of releaseGroupToFilteredProjects.get(group)) { + setBumpIfNotNone( + project, + args.bump || + (await promptForVersion( + `How do you want to bump the version of the project "${project}" within group "${group.name}"?` + )) + ); + } + } else { + setBumpIfNotNone( + group.name, + args.bump || + (await promptForVersion( + `How do you want to bump the versions of the projects in the group "${group.name}"?` + )) + ); + } + } } - } - if (!Object.keys(versionPlanBumps).length) { - output.warn({ - title: - 'No version bumps were selected so no version plan file was created.', - }); - return 0; - } - - const versionPlanMessage = args.message || (await promptForMessage()); - const versionPlanFileContent = generateVersionPlanContent( - versionPlanBumps, - versionPlanMessage - ); - const versionPlanFileName = `version-plan-${new Date().getTime()}.md`; + if (!Object.keys(versionPlanBumps).length) { + output.warn({ + title: + 'No version bumps were selected so no version plan file was created.', + }); + return 0; + } - if (args.dryRun) { - output.logSingleLine( - `Would create version plan file "${versionPlanFileName}", but --dry-run was set.` + const versionPlanMessage = args.message || (await promptForMessage()); + const versionPlanFileContent = generateVersionPlanContent( + versionPlanBumps, + versionPlanMessage ); - printDiff('', versionPlanFileContent, 1); - } else { - output.logSingleLine(`Creating version plan file "${versionPlanFileName}"`); - printDiff('', versionPlanFileContent, 1); - - const versionPlansAbsolutePath = getVersionPlansAbsolutePath(); - await ensureDir(versionPlansAbsolutePath); - await writeFile( - join(versionPlansAbsolutePath, versionPlanFileName), - versionPlanFileContent - ); - } + const versionPlanFileName = `version-plan-${new Date().getTime()}.md`; - return 0; + if (args.dryRun) { + output.logSingleLine( + `Would create version plan file "${versionPlanFileName}", but --dry-run was set.` + ); + printDiff('', versionPlanFileContent, 1); + } else { + output.logSingleLine( + `Creating version plan file "${versionPlanFileName}"` + ); + printDiff('', versionPlanFileContent, 1); + + const versionPlansAbsolutePath = getVersionPlansAbsolutePath(); + await ensureDir(versionPlansAbsolutePath); + await writeFile( + join(versionPlansAbsolutePath, versionPlanFileName), + versionPlanFileContent + ); + } + + return 0; + }; } async function promptForVersion(message: string): Promise { diff --git a/packages/nx/src/command-line/release/publish.ts b/packages/nx/src/command-line/release/publish.ts index 5320407dd245d..080fbf44d77d8 100644 --- a/packages/nx/src/command-line/release/publish.ts +++ b/packages/nx/src/command-line/release/publish.ts @@ -1,4 +1,8 @@ -import { NxJsonConfiguration, readNxJson } from '../../config/nx-json'; +import { + NxJsonConfiguration, + NxReleaseConfiguration, + readNxJson, +} from '../../config/nx-json'; import { ProjectGraph, ProjectGraphProjectNode, @@ -19,82 +23,120 @@ import { createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { filterReleaseGroups } from './config/filter-release-groups'; +import { printConfigAndExit } from './utils/print-config'; export const releasePublishCLIHandler = (args: PublishOptions) => - handleErrors(args.verbose, () => releasePublish(args, true)); - -/** - * NOTE: This function is also exported for programmatic usage and forms part of the public API - * of Nx. We intentionally do not wrap the implementation with handleErrors because users need - * to have control over their own error handling when using the API. - */ -export async function releasePublish( - args: PublishOptions, - isCLI = false -): Promise { + handleErrors(args.verbose, () => createAPI({})(args, true)); + +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { /** - * When used via the CLI, the args object will contain a __overrides_unparsed__ property that is - * important for invoking the relevant executor behind the scenes. - * - * We intentionally do not include that in the function signature, however, so as not to cause - * confusing errors for programmatic consumers of this function. + * NOTE: This function is also exported for programmatic usage and forms part of the public API + * of Nx. We intentionally do not wrap the implementation with handleErrors because users need + * to have control over their own error handling when using the API. */ - const _args = args as PublishOptions & { __overrides_unparsed__: string[] }; + return async function releasePublish( + args: PublishOptions, + isCLI = false + ): Promise { + /** + * When used via the CLI, the args object will contain a __overrides_unparsed__ property that is + * important for invoking the relevant executor behind the scenes. + * + * We intentionally do not include that in the function signature, however, so as not to cause + * confusing errors for programmatic consumers of this function. + */ + const _args = args as PublishOptions & { __overrides_unparsed__: string[] }; - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const nxJson = readNxJson(); + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - if (_args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } + if (_args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } - // Apply default configuration to any optional user configuration - const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( - projectGraph, - await createProjectFileMapUsingProjectGraph(projectGraph), - nxJson.release - ); - if (configError) { - return await handleNxReleaseConfigError(configError); - } + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + userProvidedReleaseConfig + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', + }); + } - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - _args.projects, - _args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + _args.projects, + _args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } - /** - * If the user is filtering to a subset of projects or groups, we should not run the publish task - * for dependencies, because that could cause projects outset of the filtered set to be published. - */ - const shouldExcludeTaskDependencies = - _args.projects?.length > 0 || - _args.groups?.length > 0 || - args.excludeTaskDependencies; + /** + * If the user is filtering to a subset of projects or groups, we should not run the publish task + * for dependencies, because that could cause projects outset of the filtered set to be published. + */ + const shouldExcludeTaskDependencies = + _args.projects?.length > 0 || + _args.groups?.length > 0 || + args.excludeTaskDependencies; - let overallExitStatus = 0; + let overallExitStatus = 0; + + if (args.projects?.length) { + /** + * Run publishing for all remaining release groups and filtered projects within them + */ + for (const releaseGroup of releaseGroups) { + const status = await runPublishOnProjects( + _args, + projectGraph, + nxJson, + Array.from(releaseGroupToFilteredProjects.get(releaseGroup)), + isCLI, + { + excludeTaskDependencies: shouldExcludeTaskDependencies, + loadDotEnvFiles: process.env.NX_LOAD_DOT_ENV_FILES !== 'false', + } + ); + if (status !== 0) { + overallExitStatus = status || 1; + } + } + + return overallExitStatus; + } - if (args.projects?.length) { /** - * Run publishing for all remaining release groups and filtered projects within them + * Run publishing for all remaining release groups */ for (const releaseGroup of releaseGroups) { const status = await runPublishOnProjects( _args, projectGraph, nxJson, - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)), + releaseGroup.projects, isCLI, { excludeTaskDependencies: shouldExcludeTaskDependencies, @@ -107,29 +149,7 @@ export async function releasePublish( } return overallExitStatus; - } - - /** - * Run publishing for all remaining release groups - */ - for (const releaseGroup of releaseGroups) { - const status = await runPublishOnProjects( - _args, - projectGraph, - nxJson, - releaseGroup.projects, - isCLI, - { - excludeTaskDependencies: shouldExcludeTaskDependencies, - loadDotEnvFiles: process.env.NX_LOAD_DOT_ENV_FILES !== 'false', - } - ); - if (status !== 0) { - overallExitStatus = status || 1; - } - } - - return overallExitStatus; + }; } async function runPublishOnProjects( diff --git a/packages/nx/src/command-line/release/release.ts b/packages/nx/src/command-line/release/release.ts index 7ec8c6f9f4b62..7a4e7e4a18d3b 100644 --- a/packages/nx/src/command-line/release/release.ts +++ b/packages/nx/src/command-line/release/release.ts @@ -1,299 +1,326 @@ import { prompt } from 'enquirer'; import { removeSync } from 'fs-extra'; -import { readNxJson } from '../../config/nx-json'; +import { NxReleaseConfiguration, readNxJson } from '../../config/nx-json'; import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils'; import { createProjectGraphAsync } from '../../project-graph/project-graph'; import { output } from '../../utils/output'; import { handleErrors } from '../../utils/params'; -import { releaseChangelog, shouldCreateGitHubRelease } from './changelog'; +import { + createAPI as createReleaseChangelogAPI, + shouldCreateGitHubRelease, +} from './changelog'; import { ReleaseOptions, VersionOptions } from './command-object'; import { IMPLICIT_DEFAULT_RELEASE_GROUP, createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { filterReleaseGroups } from './config/filter-release-groups'; import { readRawVersionPlans, setVersionPlansOnGroups, } from './config/version-plans'; -import { releasePublish } from './publish'; +import { createAPI as createReleasePublishAPI } from './publish'; import { getCommitHash, gitAdd, gitCommit, gitPush, gitTag } from './utils/git'; import { createOrUpdateGithubRelease } from './utils/github'; +import { printConfigAndExit } from './utils/print-config'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { createCommitMessageValues, createGitTagValues, handleDuplicateGitTags, } from './utils/shared'; -import { NxReleaseVersionResult, releaseVersion } from './version'; +import { + NxReleaseVersionResult, + createAPI as createReleaseVersionAPI, +} from './version'; export const releaseCLIHandler = (args: VersionOptions) => - handleErrors(args.verbose, () => release(args)); + handleErrors(args.verbose, () => createAPI({})(args)); + +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + const releaseVersion = createReleaseVersionAPI(overrideReleaseConfig); + const releaseChangelog = createReleaseChangelogAPI(overrideReleaseConfig); + const releasePublish = createReleasePublishAPI(overrideReleaseConfig); + + return async function release( + args: ReleaseOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); -export async function release( - args: ReleaseOptions -): Promise { - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const nxJson = readNxJson(); + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } - if (args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } + const hasVersionGitConfig = + Object.keys(userProvidedReleaseConfig.version?.git ?? {}).length > 0; + const hasChangelogGitConfig = + Object.keys(userProvidedReleaseConfig.changelog?.git ?? {}).length > 0; + if (hasVersionGitConfig || hasChangelogGitConfig) { + const jsonConfigErrorPath = hasVersionGitConfig + ? ['release', 'version', 'git'] + : ['release', 'changelog', 'git']; + const nxJsonMessage = await resolveNxJsonConfigErrorMessage( + jsonConfigErrorPath + ); + output.error({ + title: `The "release" top level command cannot be used with granular git configuration. Instead, configure git options in the "release.git" property in nx.json, or use the version, changelog, and publish subcommands or programmatic API directly.`, + bodyLines: [nxJsonMessage], + }); + process.exit(1); + } - const hasVersionGitConfig = - Object.keys(nxJson.release?.version?.git ?? {}).length > 0; - const hasChangelogGitConfig = - Object.keys(nxJson.release?.changelog?.git ?? {}).length > 0; - if (hasVersionGitConfig || hasChangelogGitConfig) { - const jsonConfigErrorPath = hasVersionGitConfig - ? ['release', 'version', 'git'] - : ['release', 'changelog', 'git']; - const nxJsonMessage = await resolveNxJsonConfigErrorMessage( - jsonConfigErrorPath + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + userProvidedReleaseConfig ); - output.error({ - title: `The "release" top level command cannot be used with granular git configuration. Instead, configure git options in the "release.git" property in nx.json, or use the version, changelog, and publish subcommands or programmatic API directly.`, - bodyLines: [nxJsonMessage], - }); - process.exit(1); - } - - // Apply default configuration to any optional user configuration - const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( - projectGraph, - await createProjectFileMapUsingProjectGraph(projectGraph), - nxJson.release - ); - if (configError) { - return await handleNxReleaseConfigError(configError); - } - - // These properties must never be undefined as this command should - // always explicitly override the git operations of the subcommands. - const shouldCommit = nxJson.release?.git?.commit ?? true; - const shouldStage = - (shouldCommit || nxJson.release?.git?.stageChanges) ?? false; - const shouldTag = nxJson.release?.git?.tag ?? true; - - const versionResult: NxReleaseVersionResult = await releaseVersion({ - ...args, - stageChanges: shouldStage, - gitCommit: false, - gitTag: false, - deleteVersionPlans: false, - }); - - const changelogResult = await releaseChangelog({ - ...args, - versionData: versionResult.projectsVersionData, - version: versionResult.workspaceVersion, - stageChanges: shouldStage, - gitCommit: false, - gitTag: false, - createRelease: false, - deleteVersionPlans: false, - }); - - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - args.projects, - args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } - const rawVersionPlans = await readRawVersionPlans(); - setVersionPlansOnGroups( - rawVersionPlans, - releaseGroups, - Object.keys(projectGraph.nodes) - ); - - const planFiles = new Set(); - releaseGroups.forEach((group) => { - if (group.versionPlans) { - if (group.name === IMPLICIT_DEFAULT_RELEASE_GROUP) { - output.logSingleLine(`Removing version plan files`); - } else { - output.logSingleLine( - `Removing version plan files for group ${group.name}` - ); - } - group.versionPlans.forEach((plan) => { - if (!args.dryRun) { - removeSync(plan.absolutePath); - if (args.verbose) { - console.log(`Removing ${plan.relativePath}`); - } - } else { - if (args.verbose) { - console.log( - `Would remove ${plan.relativePath}, but --dry-run was set` - ); - } - } - planFiles.add(plan.relativePath); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', }); } - }); - const deletedFiles = Array.from(planFiles); - if (deletedFiles.length > 0) { - await gitAdd({ - changedFiles: [], - deletedFiles, - dryRun: args.dryRun, - verbose: args.verbose, - }); - } - if (shouldCommit) { - output.logSingleLine(`Committing changes with git`); + // These properties must never be undefined as this command should + // always explicitly override the git operations of the subcommands. + const shouldCommit = userProvidedReleaseConfig.git?.commit ?? true; + const shouldStage = + (shouldCommit || userProvidedReleaseConfig.git?.stageChanges) ?? false; + const shouldTag = userProvidedReleaseConfig.git?.tag ?? true; + + const versionResult: NxReleaseVersionResult = await releaseVersion({ + ...args, + stageChanges: shouldStage, + gitCommit: false, + gitTag: false, + deleteVersionPlans: false, + }); - const commitMessage: string | undefined = nxReleaseConfig.git.commitMessage; + const changelogResult = await releaseChangelog({ + ...args, + versionData: versionResult.projectsVersionData, + version: versionResult.workspaceVersion, + stageChanges: shouldStage, + gitCommit: false, + gitTag: false, + createRelease: false, + deleteVersionPlans: false, + }); - const commitMessageValues: string[] = createCommitMessageValues( + const { + error: filterError, releaseGroups, releaseGroupToFilteredProjects, - versionResult.projectsVersionData, - commitMessage + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + Object.keys(projectGraph.nodes) ); - await gitCommit({ - messages: commitMessageValues, - additionalArgs: nxReleaseConfig.git.commitArgs, - dryRun: args.dryRun, - verbose: args.verbose, + const planFiles = new Set(); + releaseGroups.forEach((group) => { + if (group.versionPlans) { + if (group.name === IMPLICIT_DEFAULT_RELEASE_GROUP) { + output.logSingleLine(`Removing version plan files`); + } else { + output.logSingleLine( + `Removing version plan files for group ${group.name}` + ); + } + group.versionPlans.forEach((plan) => { + if (!args.dryRun) { + removeSync(plan.absolutePath); + if (args.verbose) { + console.log(`Removing ${plan.relativePath}`); + } + } else { + if (args.verbose) { + console.log( + `Would remove ${plan.relativePath}, but --dry-run was set` + ); + } + } + planFiles.add(plan.relativePath); + }); + } }); - } + const deletedFiles = Array.from(planFiles); + if (deletedFiles.length > 0) { + await gitAdd({ + changedFiles: [], + deletedFiles, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } - if (shouldTag) { - output.logSingleLine(`Tagging commit with git`); + if (shouldCommit) { + output.logSingleLine(`Committing changes with git`); - // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command - const gitTagValues: string[] = createGitTagValues( - releaseGroups, - releaseGroupToFilteredProjects, - versionResult.projectsVersionData - ); - handleDuplicateGitTags(gitTagValues); + const commitMessage: string | undefined = + nxReleaseConfig.git.commitMessage; - for (const tag of gitTagValues) { - await gitTag({ - tag, - message: nxReleaseConfig.git.tagMessage, - additionalArgs: nxReleaseConfig.git.tagArgs, + const commitMessageValues: string[] = createCommitMessageValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionResult.projectsVersionData, + commitMessage + ); + + await gitCommit({ + messages: commitMessageValues, + additionalArgs: nxReleaseConfig.git.commitArgs, dryRun: args.dryRun, verbose: args.verbose, }); } - } - const shouldCreateWorkspaceRelease = shouldCreateGitHubRelease( - nxReleaseConfig.changelog.workspaceChangelog - ); + if (shouldTag) { + output.logSingleLine(`Tagging commit with git`); + + // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command + const gitTagValues: string[] = createGitTagValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionResult.projectsVersionData + ); + handleDuplicateGitTags(gitTagValues); + + for (const tag of gitTagValues) { + await gitTag({ + tag, + message: nxReleaseConfig.git.tagMessage, + additionalArgs: nxReleaseConfig.git.tagArgs, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } + } - let hasPushedChanges = false; - let latestCommit: string | undefined; + const shouldCreateWorkspaceRelease = shouldCreateGitHubRelease( + nxReleaseConfig.changelog.workspaceChangelog + ); - if (shouldCreateWorkspaceRelease && changelogResult.workspaceChangelog) { - output.logSingleLine(`Pushing to git remote`); + let hasPushedChanges = false; + let latestCommit: string | undefined; - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - dryRun: args.dryRun, - verbose: args.verbose, - }); + if (shouldCreateWorkspaceRelease && changelogResult.workspaceChangelog) { + output.logSingleLine(`Pushing to git remote`); - hasPushedChanges = true; + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + dryRun: args.dryRun, + verbose: args.verbose, + }); - output.logSingleLine(`Creating GitHub Release`); + hasPushedChanges = true; - latestCommit = await getCommitHash('HEAD'); - await createOrUpdateGithubRelease( - changelogResult.workspaceChangelog.releaseVersion, - changelogResult.workspaceChangelog.contents, - latestCommit, - { dryRun: args.dryRun } - ); - } + output.logSingleLine(`Creating GitHub Release`); - for (const releaseGroup of releaseGroups) { - const shouldCreateProjectReleases = shouldCreateGitHubRelease( - releaseGroup.changelog - ); + latestCommit = await getCommitHash('HEAD'); + await createOrUpdateGithubRelease( + changelogResult.workspaceChangelog.releaseVersion, + changelogResult.workspaceChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + } - if (shouldCreateProjectReleases && changelogResult.projectChangelogs) { - const projects = args.projects?.length - ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)) - : // Otherwise, we use the full list of projects within the release group - releaseGroup.projects; - const projectNodes = projects.map((name) => projectGraph.nodes[name]); - - for (const project of projectNodes) { - const changelog = changelogResult.projectChangelogs[project.name]; - if (!changelog) { - continue; - } + for (const releaseGroup of releaseGroups) { + const shouldCreateProjectReleases = shouldCreateGitHubRelease( + releaseGroup.changelog + ); + + if (shouldCreateProjectReleases && changelogResult.projectChangelogs) { + const projects = args.projects?.length + ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group + Array.from(releaseGroupToFilteredProjects.get(releaseGroup)) + : // Otherwise, we use the full list of projects within the release group + releaseGroup.projects; + const projectNodes = projects.map((name) => projectGraph.nodes[name]); + + for (const project of projectNodes) { + const changelog = changelogResult.projectChangelogs[project.name]; + if (!changelog) { + continue; + } - if (!hasPushedChanges) { - output.logSingleLine(`Pushing to git remote`); + if (!hasPushedChanges) { + output.logSingleLine(`Pushing to git remote`); - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - dryRun: args.dryRun, - verbose: args.verbose, - }); + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + dryRun: args.dryRun, + verbose: args.verbose, + }); - hasPushedChanges = true; - } + hasPushedChanges = true; + } - output.logSingleLine(`Creating GitHub Release`); + output.logSingleLine(`Creating GitHub Release`); - if (!latestCommit) { - latestCommit = await getCommitHash('HEAD'); - } + if (!latestCommit) { + latestCommit = await getCommitHash('HEAD'); + } - await createOrUpdateGithubRelease( - changelog.releaseVersion, - changelog.contents, - latestCommit, - { dryRun: args.dryRun } - ); + await createOrUpdateGithubRelease( + changelog.releaseVersion, + changelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + } } } - } - let hasNewVersion = false; - // null means that all projects are versioned together but there were no changes - if (versionResult.workspaceVersion !== null) { - hasNewVersion = Object.values(versionResult.projectsVersionData).some( - (version) => version.newVersion !== null - ); - } + let hasNewVersion = false; + // null means that all projects are versioned together but there were no changes + if (versionResult.workspaceVersion !== null) { + hasNewVersion = Object.values(versionResult.projectsVersionData).some( + (version) => version.newVersion !== null + ); + } - let shouldPublish = !!args.yes && !args.skipPublish && hasNewVersion; - const shouldPromptPublishing = - !args.yes && !args.skipPublish && !args.dryRun && hasNewVersion; + let shouldPublish = !!args.yes && !args.skipPublish && hasNewVersion; + const shouldPromptPublishing = + !args.yes && !args.skipPublish && !args.dryRun && hasNewVersion; - if (shouldPromptPublishing) { - shouldPublish = await promptForPublish(); - } + if (shouldPromptPublishing) { + shouldPublish = await promptForPublish(); + } - if (shouldPublish) { - await releasePublish(args); - } else { - output.logSingleLine('Skipped publishing packages.'); - } + if (shouldPublish) { + await releasePublish(args); + } else { + output.logSingleLine('Skipped publishing packages.'); + } - return versionResult; + return versionResult; + }; } async function promptForPublish(): Promise { diff --git a/packages/nx/src/command-line/release/utils/print-config.ts b/packages/nx/src/command-line/release/utils/print-config.ts new file mode 100644 index 0000000000000..c139d1ac210ff --- /dev/null +++ b/packages/nx/src/command-line/release/utils/print-config.ts @@ -0,0 +1,54 @@ +import type { NxReleaseConfiguration } from '../../../config/nx-json'; +import { output } from '../../../utils/output'; +import type { NxReleaseConfig } from '../config/config'; + +export function printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug, +}: { + userProvidedReleaseConfig: NxReleaseConfiguration; + nxReleaseConfig: NxReleaseConfig; + isDebug: boolean; +}): any { + if (isDebug) { + console.log( + '============================================================= START FINAL INTERNAL CONFIG' + ); + console.log(JSON.stringify(nxReleaseConfig, null, 2)); + console.log( + '============================================================= END FINAL INTERNAL CONFIG' + ); + console.log(''); + console.log( + '============================================================= START USER CONFIG' + ); + console.log(JSON.stringify(userProvidedReleaseConfig, null, 2)); + console.log( + '============================================================= END USER CONFIG' + ); + output.log({ + title: 'Resolved Nx Release Configuration', + bodyLines: [ + 'NOTE: --printConfig was set to debug, so the above output contains two different resolved configs:', + '', + '- The config immediately above is the user config, the one provided by you in nx.json and/or the programmatic API.', + '- The config above that is the low level resolved configuration object used internally by nx release. It can be referenced for advanced troubleshooting.', + '', + 'For the user-facing configuration format, and the full list of available options, please reference https://nx.dev/reference/nx-json#release', + ], + }); + process.exit(0); + } + + console.log(JSON.stringify(userProvidedReleaseConfig, null, 2)); + + output.log({ + title: 'Resolved Nx Release Configuration', + bodyLines: [ + 'The above config is the result of merging any nx release config in nx.json and/or the programmatic API.', + '', + 'For details on the configuration format, and the full list of available options, please reference https://nx.dev/reference/nx-json#release', + ], + }); +} diff --git a/packages/nx/src/command-line/release/version.ts b/packages/nx/src/command-line/release/version.ts index ef5f68437f916..8be3ecf745694 100644 --- a/packages/nx/src/command-line/release/version.ts +++ b/packages/nx/src/command-line/release/version.ts @@ -3,7 +3,11 @@ import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { relative } from 'node:path'; import { Generator } from '../../config/misc-interfaces'; -import { NxJsonConfiguration, readNxJson } from '../../config/nx-json'; +import { + NxJsonConfiguration, + NxReleaseConfiguration, + readNxJson, +} from '../../config/nx-json'; import { ProjectGraph, ProjectGraphProjectNode, @@ -26,6 +30,7 @@ import { createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { ReleaseGroupWithName, filterReleaseGroups, @@ -37,6 +42,7 @@ import { import { batchProjectsByGeneratorConfig } from './utils/batch-projects-by-generator-config'; import { gitAdd, gitTag } from './utils/git'; import { printDiff } from './utils/print-changes'; +import { printConfigAndExit } from './utils/print-config'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { ReleaseVersionGeneratorResult, @@ -101,114 +107,269 @@ export interface NxReleaseVersionResult { } export const releaseVersionCLIHandler = (args: VersionOptions) => - handleErrors(args.verbose, () => releaseVersion(args)); - -/** - * NOTE: This function is also exported for programmatic usage and forms part of the public API - * of Nx. We intentionally do not wrap the implementation with handleErrors because users need - * to have control over their own error handling when using the API. - */ -export async function releaseVersion( - args: VersionOptions -): Promise { - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const { projects } = readProjectsConfigurationFromProjectGraph(projectGraph); - const nxJson = readNxJson(); - - if (args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } + handleErrors(args.verbose, () => createAPI({})(args)); - // Apply default configuration to any optional user configuration - const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( - projectGraph, - await createProjectFileMapUsingProjectGraph(projectGraph), - nxJson.release - ); - if (configError) { - return await handleNxReleaseConfigError(configError); - } +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + /** + * NOTE: This function is also exported for programmatic usage and forms part of the public API + * of Nx. We intentionally do not wrap the implementation with handleErrors because users need + * to have control over their own error handling when using the API. + */ + return async function releaseVersion( + args: VersionOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const { projects } = + readProjectsConfigurationFromProjectGraph(projectGraph); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - // The nx release top level command will always override these three git args. This is how we can tell - // if the top level release command was used or if the user is using the changelog subcommand. - // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, - // as all of the git options would be overridden anyway. - if ( - (args.gitCommit === undefined || - args.gitTag === undefined || - args.stageChanges === undefined) && - nxJson.release?.git - ) { - const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ - 'release', - 'git', - ]); - output.error({ - title: `The "release.git" property in nx.json may not be used with the "nx release version" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, - bodyLines: [nxJsonMessage], + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } + + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + userProvidedReleaseConfig + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', + }); + } + + // The nx release top level command will always override these three git args. This is how we can tell + // if the top level release command was used or if the user is using the changelog subcommand. + // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, + // as all of the git options would be overridden anyway. + if ( + (args.gitCommit === undefined || + args.gitTag === undefined || + args.stageChanges === undefined) && + userProvidedReleaseConfig.git + ) { + const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ + 'release', + 'git', + ]); + output.error({ + title: `The "release.git" property in nx.json may not be used with the "nx release version" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, + bodyLines: [nxJsonMessage], + }); + process.exit(1); + } + + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + Object.keys(projectGraph.nodes) + ); + + if (args.deleteVersionPlans === undefined) { + // default to not delete version plans after versioning as they may be needed for changelog generation + args.deleteVersionPlans = false; + } + + runPreVersionCommand(nxReleaseConfig.version.preVersionCommand, { + dryRun: args.dryRun, + verbose: args.verbose, }); - process.exit(1); - } - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - args.projects, - args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } - const rawVersionPlans = await readRawVersionPlans(); - setVersionPlansOnGroups( - rawVersionPlans, - releaseGroups, - Object.keys(projectGraph.nodes) - ); + const tree = new FsTree(workspaceRoot, args.verbose); - if (args.deleteVersionPlans === undefined) { - // default to not delete version plans after versioning as they may be needed for changelog generation - args.deleteVersionPlans = false; - } + const versionData: VersionData = {}; + const commitMessage: string | undefined = + args.gitCommitMessage || nxReleaseConfig.version.git.commitMessage; + const generatorCallbacks: (() => Promise)[] = []; - runPreVersionCommand(nxReleaseConfig.version.preVersionCommand, { - dryRun: args.dryRun, - verbose: args.verbose, - }); + /** + * additionalChangedFiles are files which need to be updated as a side-effect of versioning (such as package manager lock files), + * and need to get staged and committed as part of the existing commit, if applicable. + */ + const additionalChangedFiles = new Set(); + const additionalDeletedFiles = new Set(); + + if (args.projects?.length) { + /** + * Run versioning for all remaining release groups and filtered projects within them + */ + for (const releaseGroup of releaseGroups) { + const releaseGroupName = releaseGroup.name; + const releaseGroupProjectNames = Array.from( + releaseGroupToFilteredProjects.get(releaseGroup) + ); + const projectBatches = batchProjectsByGeneratorConfig( + projectGraph, + releaseGroup, + // Only batch based on the filtered projects within the release group + releaseGroupProjectNames + ); + + for (const [ + generatorConfigString, + projectNames, + ] of projectBatches.entries()) { + const [generatorName, generatorOptions] = JSON.parse( + generatorConfigString + ); + // Resolve the generator for the batch and run versioning on the projects within the batch + const generatorData = resolveGeneratorData({ + ...extractGeneratorCollectionAndName( + `batch "${JSON.stringify( + projectNames + )}" for release-group "${releaseGroupName}"`, + generatorName + ), + configGeneratorOptions: generatorOptions, + // all project data from the project graph (not to be confused with projectNamesToRunVersionOn) + projects, + }); + const generatorCallback = await runVersionOnProjects( + projectGraph, + nxJson, + args, + tree, + generatorData, + args.generatorOptionsOverrides, + projectNames, + releaseGroup, + versionData, + nxReleaseConfig.conventionalCommits + ); + // Capture the callback so that we can run it after flushing the changes to disk + generatorCallbacks.push(async () => { + const result = await generatorCallback(tree, { + dryRun: !!args.dryRun, + verbose: !!args.verbose, + generatorOptions: { + ...generatorOptions, + ...args.generatorOptionsOverrides, + }, + }); + const { changedFiles, deletedFiles } = + parseGeneratorCallbackResult(result); + changedFiles.forEach((f) => additionalChangedFiles.add(f)); + deletedFiles.forEach((f) => additionalDeletedFiles.add(f)); + }); + } + } - const tree = new FsTree(workspaceRoot, args.verbose); + // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command + const gitTagValues: string[] = + args.gitTag ?? nxReleaseConfig.version.git.tag + ? createGitTagValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionData + ) + : []; + handleDuplicateGitTags(gitTagValues); + + printAndFlushChanges(tree, !!args.dryRun); + + for (const generatorCallback of generatorCallbacks) { + await generatorCallback(); + } - const versionData: VersionData = {}; - const commitMessage: string | undefined = - args.gitCommitMessage || nxReleaseConfig.version.git.commitMessage; - const generatorCallbacks: (() => Promise)[] = []; + const changedFiles = [ + ...tree.listChanges().map((f) => f.path), + ...additionalChangedFiles, + ]; + + // No further actions are necessary in this scenario (e.g. if conventional commits detected no changes) + if (!changedFiles.length) { + return { + // An overall workspace version cannot be relevant when filtering to independent projects + workspaceVersion: undefined, + projectsVersionData: versionData, + }; + } - /** - * additionalChangedFiles are files which need to be updated as a side-effect of versioning (such as package manager lock files), - * and need to get staged and committed as part of the existing commit, if applicable. - */ - const additionalChangedFiles = new Set(); - const additionalDeletedFiles = new Set(); + if (args.gitCommit ?? nxReleaseConfig.version.git.commit) { + await commitChanges({ + changedFiles, + deletedFiles: Array.from(additionalDeletedFiles), + isDryRun: !!args.dryRun, + isVerbose: !!args.verbose, + gitCommitMessages: createCommitMessageValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionData, + commitMessage + ), + gitCommitArgs: + args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs, + }); + } else if ( + args.stageChanges ?? + nxReleaseConfig.version.git.stageChanges + ) { + output.logSingleLine(`Staging changed files with git`); + await gitAdd({ + changedFiles, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } + + if (args.gitTag ?? nxReleaseConfig.version.git.tag) { + output.logSingleLine(`Tagging commit with git`); + for (const tag of gitTagValues) { + await gitTag({ + tag, + message: + args.gitTagMessage || nxReleaseConfig.version.git.tagMessage, + additionalArgs: + args.gitTagArgs || nxReleaseConfig.version.git.tagArgs, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } + } + + return { + // An overall workspace version cannot be relevant when filtering to independent projects + workspaceVersion: undefined, + projectsVersionData: versionData, + }; + } - if (args.projects?.length) { /** - * Run versioning for all remaining release groups and filtered projects within them + * Run versioning for all remaining release groups */ for (const releaseGroup of releaseGroups) { const releaseGroupName = releaseGroup.name; - const releaseGroupProjectNames = Array.from( - releaseGroupToFilteredProjects.get(releaseGroup) - ); const projectBatches = batchProjectsByGeneratorConfig( projectGraph, releaseGroup, - // Only batch based on the filtered projects within the release group - releaseGroupProjectNames + // Batch based on all projects within the release group + releaseGroup.projects ); for (const [ @@ -277,6 +438,18 @@ export async function releaseVersion( await generatorCallback(); } + // Only applicable when there is a single release group with a fixed relationship + let workspaceVersion: string | null | undefined = undefined; + if (releaseGroups.length === 1) { + const releaseGroup = releaseGroups[0]; + if (releaseGroup.projectsRelationship === 'fixed') { + const releaseGroupProjectNames = Array.from( + releaseGroupToFilteredProjects.get(releaseGroup) + ); + workspaceVersion = versionData[releaseGroupProjectNames[0]].newVersion; // all projects have the same version so we can just grab the first + } + } + const changedFiles = [ ...tree.listChanges().map((f) => f.path), ...additionalChangedFiles, @@ -286,8 +459,7 @@ export async function releaseVersion( // No further actions are necessary in this scenario (e.g. if conventional commits detected no changes) if (!changedFiles.length && !deletedFiles.length) { return { - // An overall workspace version cannot be relevant when filtering to independent projects - workspaceVersion: undefined, + workspaceVersion, projectsVersionData: versionData, }; } @@ -331,156 +503,10 @@ export async function releaseVersion( } } - return { - // An overall workspace version cannot be relevant when filtering to independent projects - workspaceVersion: undefined, - projectsVersionData: versionData, - }; - } - - /** - * Run versioning for all remaining release groups - */ - for (const releaseGroup of releaseGroups) { - const releaseGroupName = releaseGroup.name; - const projectBatches = batchProjectsByGeneratorConfig( - projectGraph, - releaseGroup, - // Batch based on all projects within the release group - releaseGroup.projects - ); - - for (const [ - generatorConfigString, - projectNames, - ] of projectBatches.entries()) { - const [generatorName, generatorOptions] = JSON.parse( - generatorConfigString - ); - // Resolve the generator for the batch and run versioning on the projects within the batch - const generatorData = resolveGeneratorData({ - ...extractGeneratorCollectionAndName( - `batch "${JSON.stringify( - projectNames - )}" for release-group "${releaseGroupName}"`, - generatorName - ), - configGeneratorOptions: generatorOptions, - // all project data from the project graph (not to be confused with projectNamesToRunVersionOn) - projects, - }); - const generatorCallback = await runVersionOnProjects( - projectGraph, - nxJson, - args, - tree, - generatorData, - args.generatorOptionsOverrides, - projectNames, - releaseGroup, - versionData, - nxReleaseConfig.conventionalCommits - ); - // Capture the callback so that we can run it after flushing the changes to disk - generatorCallbacks.push(async () => { - const result = await generatorCallback(tree, { - dryRun: !!args.dryRun, - verbose: !!args.verbose, - generatorOptions: { - ...generatorOptions, - ...args.generatorOptionsOverrides, - }, - }); - const { changedFiles, deletedFiles } = - parseGeneratorCallbackResult(result); - changedFiles.forEach((f) => additionalChangedFiles.add(f)); - deletedFiles.forEach((f) => additionalDeletedFiles.add(f)); - }); - } - } - - // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command - const gitTagValues: string[] = - args.gitTag ?? nxReleaseConfig.version.git.tag - ? createGitTagValues( - releaseGroups, - releaseGroupToFilteredProjects, - versionData - ) - : []; - handleDuplicateGitTags(gitTagValues); - - printAndFlushChanges(tree, !!args.dryRun); - - for (const generatorCallback of generatorCallbacks) { - await generatorCallback(); - } - - // Only applicable when there is a single release group with a fixed relationship - let workspaceVersion: string | null | undefined = undefined; - if (releaseGroups.length === 1) { - const releaseGroup = releaseGroups[0]; - if (releaseGroup.projectsRelationship === 'fixed') { - const releaseGroupProjectNames = Array.from( - releaseGroupToFilteredProjects.get(releaseGroup) - ); - workspaceVersion = versionData[releaseGroupProjectNames[0]].newVersion; // all projects have the same version so we can just grab the first - } - } - - const changedFiles = [ - ...tree.listChanges().map((f) => f.path), - ...additionalChangedFiles, - ]; - - // No further actions are necessary in this scenario (e.g. if conventional commits detected no changes) - if (!changedFiles.length) { return { workspaceVersion, projectsVersionData: versionData, }; - } - - if (args.gitCommit ?? nxReleaseConfig.version.git.commit) { - await commitChanges({ - changedFiles, - deletedFiles: Array.from(additionalDeletedFiles), - isDryRun: !!args.dryRun, - isVerbose: !!args.verbose, - gitCommitMessages: createCommitMessageValues( - releaseGroups, - releaseGroupToFilteredProjects, - versionData, - commitMessage - ), - gitCommitArgs: - args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs, - }); - } else if (args.stageChanges ?? nxReleaseConfig.version.git.stageChanges) { - output.logSingleLine(`Staging changed files with git`); - await gitAdd({ - changedFiles, - dryRun: args.dryRun, - verbose: args.verbose, - }); - } - - if (args.gitTag ?? nxReleaseConfig.version.git.tag) { - output.logSingleLine(`Tagging commit with git`); - for (const tag of gitTagValues) { - await gitTag({ - tag, - message: args.gitTagMessage || nxReleaseConfig.version.git.tagMessage, - additionalArgs: args.gitTagArgs || nxReleaseConfig.version.git.tagArgs, - dryRun: args.dryRun, - verbose: args.verbose, - }); - } - } - - return { - workspaceVersion, - projectsVersionData: versionData, }; } diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index 79a70af5098a4..0bb9764555caa 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -174,7 +174,7 @@ export interface NxReleaseConventionalCommitsConfiguration { >; } -interface NxReleaseConfiguration { +export interface NxReleaseConfiguration { /** * Shorthand for amending the projects which will be included in the implicit default release group (all projects by default). * @note Only one of `projects` or `groups` can be specified, the cannot be used together. From 04af849c66128f069fdc5348fb9259443ca63cf0 Mon Sep 17 00:00:00 2001 From: Juri Date: Tue, 6 Aug 2024 22:48:48 +0200 Subject: [PATCH 2/6] docs(release): optimize the customize semver recipe --- docs/generated/manifests/menus.json | 48 ++--- docs/generated/manifests/nx.json | 66 +++---- docs/generated/manifests/tags.json | 14 +- docs/map.json | 12 +- .../customize-conventional-commit-types.md | 178 +++++++++++------- docs/shared/reference/sitemap.md | 2 +- 6 files changed, 179 insertions(+), 141 deletions(-) diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index b3c1ffac95c02..e7c6310cb5aed 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -1922,6 +1922,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Customize Conventional Commit Types", + "path": "/recipes/nx-release/customize-conventional-commit-types", + "id": "customize-conventional-commit-types", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Configure Custom Registries", "path": "/recipes/nx-release/configure-custom-registries", @@ -1962,14 +1970,6 @@ "children": [], "disableCollapsible": false }, - { - "name": "Customize Conventional Commit Types", - "path": "/recipes/nx-release/customize-conventional-commit-types", - "id": "customize-conventional-commit-types", - "isExternal": false, - "children": [], - "disableCollapsible": false - }, { "name": "Configure Changelog Format", "path": "/recipes/nx-release/configure-changelog-format", @@ -3625,6 +3625,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Customize Conventional Commit Types", + "path": "/recipes/nx-release/customize-conventional-commit-types", + "id": "customize-conventional-commit-types", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Configure Custom Registries", "path": "/recipes/nx-release/configure-custom-registries", @@ -3665,14 +3673,6 @@ "children": [], "disableCollapsible": false }, - { - "name": "Customize Conventional Commit Types", - "path": "/recipes/nx-release/customize-conventional-commit-types", - "id": "customize-conventional-commit-types", - "isExternal": false, - "children": [], - "disableCollapsible": false - }, { "name": "Configure Changelog Format", "path": "/recipes/nx-release/configure-changelog-format", @@ -3716,6 +3716,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Customize Conventional Commit Types", + "path": "/recipes/nx-release/customize-conventional-commit-types", + "id": "customize-conventional-commit-types", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Configure Custom Registries", "path": "/recipes/nx-release/configure-custom-registries", @@ -3756,14 +3764,6 @@ "children": [], "disableCollapsible": false }, - { - "name": "Customize Conventional Commit Types", - "path": "/recipes/nx-release/customize-conventional-commit-types", - "id": "customize-conventional-commit-types", - "isExternal": false, - "children": [], - "disableCollapsible": false - }, { "name": "Configure Changelog Format", "path": "/recipes/nx-release/configure-changelog-format", diff --git a/docs/generated/manifests/nx.json b/docs/generated/manifests/nx.json index 4e8326939d3fc..4da156a337525 100644 --- a/docs/generated/manifests/nx.json +++ b/docs/generated/manifests/nx.json @@ -2626,6 +2626,17 @@ "path": "/recipes/nx-release/automatically-version-with-conventional-commits", "tags": ["nx-release"] }, + { + "id": "customize-conventional-commit-types", + "name": "Customize Conventional Commit Types", + "description": "", + "mediaImage": "", + "file": "shared/recipes/nx-release/customize-conventional-commit-types", + "itemList": [], + "isExternal": false, + "path": "/recipes/nx-release/customize-conventional-commit-types", + "tags": ["nx-release"] + }, { "id": "configure-custom-registries", "name": "Configure Custom Registries", @@ -2681,17 +2692,6 @@ "path": "/recipes/nx-release/update-local-registry-setup", "tags": ["nx-release"] }, - { - "id": "customize-conventional-commit-types", - "name": "Customize Conventional Commit Types", - "description": "", - "mediaImage": "", - "file": "shared/recipes/nx-release/customize-conventional-commit-types", - "itemList": [], - "isExternal": false, - "path": "/recipes/nx-release/customize-conventional-commit-types", - "tags": ["nx-release"] - }, { "id": "configure-changelog-format", "name": "Configure Changelog Format", @@ -4961,6 +4961,17 @@ "path": "/recipes/nx-release/automatically-version-with-conventional-commits", "tags": ["nx-release"] }, + { + "id": "customize-conventional-commit-types", + "name": "Customize Conventional Commit Types", + "description": "", + "mediaImage": "", + "file": "shared/recipes/nx-release/customize-conventional-commit-types", + "itemList": [], + "isExternal": false, + "path": "/recipes/nx-release/customize-conventional-commit-types", + "tags": ["nx-release"] + }, { "id": "configure-custom-registries", "name": "Configure Custom Registries", @@ -5016,17 +5027,6 @@ "path": "/recipes/nx-release/update-local-registry-setup", "tags": ["nx-release"] }, - { - "id": "customize-conventional-commit-types", - "name": "Customize Conventional Commit Types", - "description": "", - "mediaImage": "", - "file": "shared/recipes/nx-release/customize-conventional-commit-types", - "itemList": [], - "isExternal": false, - "path": "/recipes/nx-release/customize-conventional-commit-types", - "tags": ["nx-release"] - }, { "id": "configure-changelog-format", "name": "Configure Changelog Format", @@ -5087,6 +5087,17 @@ "path": "/recipes/nx-release/automatically-version-with-conventional-commits", "tags": ["nx-release"] }, + "/recipes/nx-release/customize-conventional-commit-types": { + "id": "customize-conventional-commit-types", + "name": "Customize Conventional Commit Types", + "description": "", + "mediaImage": "", + "file": "shared/recipes/nx-release/customize-conventional-commit-types", + "itemList": [], + "isExternal": false, + "path": "/recipes/nx-release/customize-conventional-commit-types", + "tags": ["nx-release"] + }, "/recipes/nx-release/configure-custom-registries": { "id": "configure-custom-registries", "name": "Configure Custom Registries", @@ -5142,17 +5153,6 @@ "path": "/recipes/nx-release/update-local-registry-setup", "tags": ["nx-release"] }, - "/recipes/nx-release/customize-conventional-commit-types": { - "id": "customize-conventional-commit-types", - "name": "Customize Conventional Commit Types", - "description": "", - "mediaImage": "", - "file": "shared/recipes/nx-release/customize-conventional-commit-types", - "itemList": [], - "isExternal": false, - "path": "/recipes/nx-release/customize-conventional-commit-types", - "tags": ["nx-release"] - }, "/recipes/nx-release/configure-changelog-format": { "id": "configure-changelog-format", "name": "Configure Changelog Format", diff --git a/docs/generated/manifests/tags.json b/docs/generated/manifests/tags.json index 92255390833ac..e74fee7dc4de2 100644 --- a/docs/generated/manifests/tags.json +++ b/docs/generated/manifests/tags.json @@ -483,6 +483,13 @@ "name": "Automatically Version with Conventional Commits", "path": "/recipes/nx-release/automatically-version-with-conventional-commits" }, + { + "description": "", + "file": "shared/recipes/nx-release/customize-conventional-commit-types", + "id": "customize-conventional-commit-types", + "name": "Customize Conventional Commit Types", + "path": "/recipes/nx-release/customize-conventional-commit-types" + }, { "description": "", "file": "shared/recipes/nx-release/configure-custom-registries", @@ -518,13 +525,6 @@ "name": "Update Your Local Registry Setup to use Nx Release", "path": "/recipes/nx-release/update-local-registry-setup" }, - { - "description": "", - "file": "shared/recipes/nx-release/customize-conventional-commit-types", - "id": "customize-conventional-commit-types", - "name": "Customize Conventional Commit Types", - "path": "/recipes/nx-release/customize-conventional-commit-types" - }, { "description": "", "file": "shared/recipes/nx-release/configure-changelog-format", diff --git a/docs/map.json b/docs/map.json index 426027a96bf4c..86821c9a5d428 100644 --- a/docs/map.json +++ b/docs/map.json @@ -998,6 +998,12 @@ "tags": ["nx-release"], "file": "shared/recipes/nx-release/automatically-version-with-conventional-commits" }, + { + "name": "Customize Conventional Commit Types", + "id": "customize-conventional-commit-types", + "tags": ["nx-release"], + "file": "shared/recipes/nx-release/customize-conventional-commit-types" + }, { "name": "Configure Custom Registries", "id": "configure-custom-registries", @@ -1028,12 +1034,6 @@ "tags": ["nx-release"], "file": "shared/recipes/nx-release/update-local-registry-setup" }, - { - "name": "Customize Conventional Commit Types", - "id": "customize-conventional-commit-types", - "tags": ["nx-release"], - "file": "shared/recipes/nx-release/customize-conventional-commit-types" - }, { "name": "Configure Changelog Format", "id": "configure-changelog-format", diff --git a/docs/shared/recipes/nx-release/customize-conventional-commit-types.md b/docs/shared/recipes/nx-release/customize-conventional-commit-types.md index dd7e22e97c040..28ee037411ed3 100644 --- a/docs/shared/recipes/nx-release/customize-conventional-commit-types.md +++ b/docs/shared/recipes/nx-release/customize-conventional-commit-types.md @@ -1,112 +1,150 @@ # Customize Conventional Commit Types -Nx Release can defer to the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard to automatically determine the next version to release. To enable this behavior for versioning, see [Automatically Version with Conventional Commits](/recipes/nx-release/automatically-version-with-conventional-commits). +[Nx release](/features/manage-releases) allows you to leverage the [conventional commits](/recipes/nx-release/automatically-version-with-conventional-commits) standard to automatically determine the next version increment. -This recipe will cover how to customize the types of commits that trigger version bumps, how to customize the version bump for each type, and how to customize the changelog entry for each commit type. +By default, this results in: -## Conventional Commits Usage within Nx Release - -The conventional commits configuration is used in two different places within Nx Release - once in the version step for determining the version bump, and once when generating changelogs. - -### Determine the Version Bump - -When `release.version.conventionalCommits` is `true` in `nx.json`, Nx Release will use the commit messages since the last release to determine the version bump. It will look at the type of each commit and determine the highest version bump from the following list: - -- 'feat' -> minor -- 'fix' -> patch - -For example, if the git history looks like this: - -``` - - fix(pkg-1): fix something - - feat(pkg-2): add a new feature - - chore(pkg-3): update docs - - chore(release): 1.0.0 -``` - -then Nx Release will select the `minor` version bump and elect to release version 1.1.0. This is because there is a `feat` commit since the last release of 1.0.0. To customize the version bump for different types of commits, or to trigger a version bump with custom commit types, see the [Configure Commit Types](#configure-commit-types) section below. +- `feat(...)` triggering a minor version bump (`1.?.0`) +- `fix(...)` triggering a patch version bump (`1.?.x`) +- `BREAKING CHANGE` in the footer of the commit message or with an exclamation mark after the commit type (`fix(...)!`) triggers a major version bump (`?.0.0`) {% callout type="info" title="No changes detected" %} -If Nx Release does not find any relevant commits since the last release, it will skip releasing a new version. This works with [independent releases](/recipes/nx-release/release-projects-independently) as well, allowing for only some projects to be released and some to be skipped. +If Nx Release does not find any relevant commits since the last release, it will skip releasing a new version. This works with [independent releases](/recipes/nx-release/release-projects-independently) as well, allowing for only some projects to be released while others are skipped. {% /callout %} -#### Breaking Changes and Major Version Bumps +However, you can customize how Nx interprets these conventional commits, for both **versioning** and **changelog** generation. -Major version bumps are triggered by the presence of a `BREAKING CHANGE` in the footer of the commit message or with '!' after the commit type and scope, as specified by the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard. This is regardless of the type or scope of the commit. For example: +## Disable a Commit Type for Versioning and Changelog Generation -``` -fix: remove deprecated config properties +To disable a commit type, set it to `false`. -BREAKING CHANGE: `settings` and `overrides` keys in config are no longer supported +```json {% fileName="nx.json" %} +{ + "release": { + "conventionalCommits": { + "types": { + // disable the docs type for versioning and in the changelog + "docs": false, + ... + } + } + } +} ``` -``` -fix!: do not trigger a workflow when user submits bad data -``` +If you just want to disable a commit type for versioning, but still want it to appear in the changelog, set `semverBump` to `none`. +```json {% fileName="nx.json" %} +{ + "release": { + "conventionalCommits": { + "types": { + // disable the docs type for versioning, but still include it in the changelog + "docs": { + "semverBump": "none", + ... + }, + ... + } + } + } +} ``` -feat(pkg-2)!: redirect users to the new workflow page -``` - -When Nx Release detects a breaking change, it will bump the major version, regardless of the other commits present in the history. Breaking changes will also appear in their own section of the changelog. -### Generate Changelog Sections +## Changing the Type of Semver Version Bump -Nx Release will sort changes within changelogs into sections based on the type of commit. By default, `fix`, `feat`, and `perf` commits will be included in the changelog. To customize the headers of changelog sections, include other commit types, or exclude the default commit types, see the [Configure Commit Types](#configure-commit-types) section below. +Assume you'd like `docs(...)` commit types to cause a `patch` version bump. You can define that as follows: -See the [Nx repo](https://github.com/nrwl/nx/releases) for an example of a changelogs generated with Nx Release. +```json {% fileName="nx.json" %} +{ + "release": { + "conventionalCommits": { + "types": { + "docs": { + "semverBump": "patch", + ... + }, + } + } + } +} +``` -## Configure Commit Types +## Renaming the Changelog Section for a Commit Type -Commit types are configured in the `release.conventionalCommits.types` property in `nx.json`: +To rename the changelog section for a commit type, set the `title` property. ```json {% fileName="nx.json" %} { "release": { "conventionalCommits": { "types": { - // disable the fix type for versioning and in the changelog - "fix": false, + ... "docs": { - "semverBump": "patch", + ... "changelog": { - "hidden": false, "title": "Documentation Changes" } }, - "perf": { - "semverBump": "none", - // omitting "hidden" will default it to false - "changelog": { - "title": "Performance Improvements" - } + ... + } + } + } +} +``` + +## Hiding a Commit Type from the Changelog + +To hide a commit type from the changelog, set `changelog` to `false`. + +```json {% fileName="nx.json" %} +{ + "release": { + "conventionalCommits": { + "types": { + ... + "chore": { + "changelog": false }, - "deps": { - "semverBump": "minor", - // omitting "hidden" will default it to false + ... + } + } + } +} +``` + +Alternatively, you can set `hidden` to `true` to achieve the same result. + +```json {% fileName="nx.json" %} +{ + "release": { + "conventionalCommits": { + "types": { + ... + "chore": { "changelog": { - "title": "Dependency Updates" + "hidden": true } }, - // unspecified semverBump will default to "patch" - "chore": { - // "changelog.hidden" defaults to true, but setting changelog: false - // is a shortcut for setting "changelog.hidden" to false. - "changelog": false - }, - // unspecified semverBump will default to "patch" - "styles": {} + ... } } } } ``` -In this example, the following types are configured: +## Defining non-standard Commit Types -- The `fix` type has been fully disabled, so `fix` commits will not trigger a version bump and will not be included in the changelog. -- The `docs` type will trigger a `patch` version bump and will have the "Documentation Changes" title in the changelog. -- The `perf` type will NOT trigger a version bump and will have the "Performance Improvements" title in the changelog. -- The `deps` type will trigger a `minor` version bump and will have the "Dependency Updates" title in the changelog. -- The `chore` type will trigger a `patch` version bump, which is the default for if `versionBump` is not specified, and will not be included in the changelog. -- The `styles` type will trigger a `patch` version bump, which is the default for if `versionBump` is not specified, and will be included in the changelog with the corresponding default title. +If you want to use custom, non-standard conventional commit types, you can define them in the `types` object. If you don't specify a `semverBump`, Nx will default to `patch`. + +```json {% fileName="nx.json" %} +{ + "release": { + "conventionalCommits": { + "types": { + "awesome": {} + } + } + } +} +``` diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 08e99c2712c9b..452582109ca47 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -157,12 +157,12 @@ - [Get Started with Nx Release](/recipes/nx-release/get-started-with-nx-release) - [Release Projects Independently](/recipes/nx-release/release-projects-independently) - [Automatically Version with Conventional Commits](/recipes/nx-release/automatically-version-with-conventional-commits) + - [Customize Conventional Commit Types](/recipes/nx-release/customize-conventional-commit-types) - [Configure Custom Registries](/recipes/nx-release/configure-custom-registries) - [Publish in CI/CD](/recipes/nx-release/publish-in-ci-cd) - [Automate GitHub Releases](/recipes/nx-release/automate-github-releases) - [Publish Rust Crates](/recipes/nx-release/publish-rust-crates) - [Update Your Local Registry Setup to use Nx Release](/recipes/nx-release/update-local-registry-setup) - - [Customize Conventional Commit Types](/recipes/nx-release/customize-conventional-commit-types) - [Configure Changelog Format](/recipes/nx-release/configure-changelog-format) - [Publish a Custom Dist Directory](/recipes/nx-release/publish-custom-dist-directory) - [Other](/recipes/other) From e74db498cacbb3f883dd18b4309899c44d19946c Mon Sep 17 00:00:00 2001 From: Juri Date: Fri, 2 Aug 2024 17:48:00 +0200 Subject: [PATCH 3/6] docs(nx-cloud): update CTA and connect instructions --- docs/nx-cloud/intro/connect-to-cloud.md | 19 ++++++++++--------- nx-dev/nx-dev/app/nx-cloud/page.tsx | 5 ++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/nx-cloud/intro/connect-to-cloud.md b/docs/nx-cloud/intro/connect-to-cloud.md index 50e171ec9e683..e97e9945c3d81 100644 --- a/docs/nx-cloud/intro/connect-to-cloud.md +++ b/docs/nx-cloud/intro/connect-to-cloud.md @@ -4,24 +4,26 @@ Nx Cloud directly integrates with your existing CI setup. ![Nx Cloud Overview](/shared/images/nx-cloud/nx-cloud-overview.webp) -In a nutshell, here's how this works: +Here's how you get set up. -**Step 1: Connect your workspace to Nx Cloud** +## Step 1: Connect your workspace to Nx Cloud -This can be done by signing up on [nx.app](https://nx.app) and then connecting to your git repository. +To connect your workspace, **push it to GitHub** (or your respective source control provider) and then run: ```shell npx nx connect ``` -**Step 2: Your CI script triggers Nx Cloud** +## Step 2: Configure your CI script + +If you have CI set up already, configure [distribution with Nx Agents](/ci/features/distribute-task-execution) as follows: ```yml - name: Start CI run run: 'npx nx-cloud start-ci-run --distribute-on="8 linux-medium-js"' ``` -Let us generate the workflow file for you, if you don't already have one. +Alternatively you can generate the CI configuration using: ```shell npx nx g ci-workflow @@ -29,16 +31,15 @@ npx nx g ci-workflow Or, check out our [recipes for the various CI providers](/ci/recipes/set-up). -**Step 3: Run your Nx commands as usual** +## Step 3: Run your Nx commands as usual ```yml - run: npx nx-cloud record -- node tools/custom-script.js -- run: npx nx affected -t lint test build -- run: npx nx affected -t e2e-ci --parallel 1 +- run: npx nx affected -t lint test build e2e-ci ``` All these commands are automatically picked up by Nx Cloud, split up into smaller tasks and distributed across the specified number of machines. Nx Cloud works with Nx tasks automatically, or you can [record non-Nx commands with `nx-cloud record`](/ci/recipes/other/record-commands). -**Step 4: All results are played back automatically** +## Step 4: All results are played back automatically Nx Cloud automatically plays back all results to your CI system, as if distribution never happened. You can continue doing post-processing on the results, like uploading test reports, deploying artifacts etc. diff --git a/nx-dev/nx-dev/app/nx-cloud/page.tsx b/nx-dev/nx-dev/app/nx-cloud/page.tsx index 1ed88e6668b4f..71346c91fe3ce 100644 --- a/nx-dev/nx-dev/app/nx-cloud/page.tsx +++ b/nx-dev/nx-dev/app/nx-cloud/page.tsx @@ -61,7 +61,10 @@ export default function NxCloudPage(): JSX.Element {
- +
); From dfd7241ed584cfac235e9b5eadbc170cc689685b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 7 Aug 2024 17:25:32 +0100 Subject: [PATCH 4/6] fix(testing): adding e2e projects should register e2e-ci targetDefaults (#27185) ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes # --- .../application/application.spec.ts | 10 +- .../src/generators/application/lib/add-e2e.ts | 17 + .../application/lib/create-project.ts | 2 +- .../src/generators/library/lib/add-project.ts | 2 +- packages/cypress/migrations.json | 6 + .../add-e2e-ci-target-defaults.spec.ts | 366 +++++++++++++++++ .../add-e2e-ci-target-defaults.ts | 102 +++++ .../generators/add-build-target-defaults.ts | 19 - .../generators/target-defaults-utils.spec.ts | 377 ++++++++++++++++++ .../src/generators/target-defaults-utils.ts | 98 +++++ .../utils/find-plugin-for-config-file.spec.ts | 173 ++++++++ .../src/utils/find-plugin-for-config-file.ts | 50 +++ .../generators/configuration/configuration.ts | 2 +- .../application/application.spec.ts | 53 +++ .../src/generators/application/lib/add-e2e.ts | 79 +++- .../generators/application/lib/add-project.ts | 2 +- .../expo/src/generators/library/library.ts | 2 +- packages/js/src/generators/library/library.ts | 2 +- .../src/generators/setup-build/generator.ts | 2 +- .../application/application.spec.ts | 70 +++- .../src/generators/application/lib/add-e2e.ts | 71 +++- .../generators/application/lib/add-project.ts | 2 +- .../src/generators/application/application.ts | 2 +- .../node/src/generators/library/library.ts | 2 +- .../__snapshots__/application.spec.ts.snap | 16 + .../application/application.spec.ts | 1 + .../src/generators/application/lib/add-e2e.ts | 57 ++- packages/nx/src/devkit-internals.ts | 4 +- .../add-e2e-ci-target-defaults.spec.ts | 333 ++++++++++++++++ .../add-e2e-ci-target-defaults.ts | 116 ++++++ .../use-serve-static-preview-for-command.ts | 2 + .../application/application.spec.ts | 95 +++++ .../src/generators/application/lib/add-e2e.ts | 84 +++- .../application/application.impl.spec.ts | 18 +- .../application/application.impl.ts | 2 +- .../src/generators/application/lib/add-e2e.ts | 77 +++- .../generators/configuration/configuration.ts | 2 +- packages/vite/src/utils/generator-utils.ts | 2 +- .../application/application.spec.ts | 9 + .../src/generators/application/lib/add-e2e.ts | 77 +++- .../application/application.spec.ts | 8 + .../src/generators/application/application.ts | 90 ++++- .../generators/configuration/configuration.ts | 2 +- tsconfig.base.json | 1 + 44 files changed, 2425 insertions(+), 82 deletions(-) create mode 100644 packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts create mode 100644 packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts delete mode 100644 packages/devkit/src/generators/add-build-target-defaults.ts create mode 100644 packages/devkit/src/generators/target-defaults-utils.spec.ts create mode 100644 packages/devkit/src/generators/target-defaults-utils.ts create mode 100644 packages/devkit/src/utils/find-plugin-for-config-file.spec.ts create mode 100644 packages/devkit/src/utils/find-plugin-for-config-file.ts create mode 100644 packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts create mode 100644 packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts diff --git a/packages/angular/src/generators/application/application.spec.ts b/packages/angular/src/generators/application/application.spec.ts index e936d428ab4f7..6ab04c4b5c7e1 100644 --- a/packages/angular/src/generators/application/application.spec.ts +++ b/packages/angular/src/generators/application/application.spec.ts @@ -569,7 +569,8 @@ describe('app', () => { it('should add eslint plugin and no lint target to e2e project', async () => { await generateApp(appTree, 'my-app', { linter: Linter.EsLint }); - expect(readNxJson(appTree).plugins).toMatchInlineSnapshot(` + const nxJson = readNxJson(appTree); + expect(nxJson.plugins).toMatchInlineSnapshot(` [ { "options": { @@ -588,6 +589,13 @@ describe('app', () => { }, ] `); + expect(nxJson.targetDefaults['e2e-ci--**/*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); expect( readProjectConfiguration(appTree, 'my-app-e2e').targets.lint ).toBeUndefined(); diff --git a/packages/angular/src/generators/application/lib/add-e2e.ts b/packages/angular/src/generators/application/lib/add-e2e.ts index 08fa9b2863df0..7c3de1a055e27 100644 --- a/packages/angular/src/generators/application/lib/add-e2e.ts +++ b/packages/angular/src/generators/application/lib/add-e2e.ts @@ -12,6 +12,7 @@ import { import { nxVersion } from '../../../utils/versions'; import { getInstalledAngularVersionInfo } from '../../utils/version-utils'; import type { NormalizedSchema } from './normalized-schema'; +import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function addE2e(tree: Tree, options: NormalizedSchema) { // since e2e are separate projects, default to adding plugins @@ -45,6 +46,14 @@ export async function addE2e(tree: Tree, options: NormalizedSchema) { rootProject: options.rootProject, addPlugin, }); + if (addPlugin) { + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + joinPathFragments(options.e2eProjectRoot, 'cypress.config.ts') + ); + } } else if (options.e2eTestRunner === 'playwright') { const { configurationGenerator } = ensurePackage< typeof import('@nx/playwright') @@ -71,6 +80,14 @@ export async function addE2e(tree: Tree, options: NormalizedSchema) { rootProject: options.rootProject, addPlugin, }); + if (addPlugin) { + await addE2eCiTargetDefaults( + tree, + '@nx/playwright/plugin', + '^build', + joinPathFragments(options.e2eProjectRoot, 'playwright.config.ts') + ); + } } } diff --git a/packages/angular/src/generators/application/lib/create-project.ts b/packages/angular/src/generators/application/lib/create-project.ts index 5451263d0a9cc..b1597ad2e4831 100644 --- a/packages/angular/src/generators/application/lib/create-project.ts +++ b/packages/angular/src/generators/application/lib/create-project.ts @@ -2,7 +2,7 @@ import { addProjectConfiguration, joinPathFragments, Tree } from '@nx/devkit'; import type { AngularProjectConfiguration } from '../../../utils/types'; import { getInstalledAngularVersionInfo } from '../../utils/version-utils'; import type { NormalizedSchema } from './normalized-schema'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export function createProject(tree: Tree, options: NormalizedSchema) { const { major: angularMajorVersion } = getInstalledAngularVersionInfo(tree); diff --git a/packages/angular/src/generators/library/lib/add-project.ts b/packages/angular/src/generators/library/lib/add-project.ts index 9ac3e015a9037..466ed991d8b74 100644 --- a/packages/angular/src/generators/library/lib/add-project.ts +++ b/packages/angular/src/generators/library/lib/add-project.ts @@ -2,7 +2,7 @@ import type { Tree } from '@nx/devkit'; import { addProjectConfiguration, joinPathFragments } from '@nx/devkit'; import type { AngularProjectConfiguration } from '../../../utils/types'; import type { NormalizedSchema } from './normalized-schema'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export function addProject( tree: Tree, diff --git a/packages/cypress/migrations.json b/packages/cypress/migrations.json index b1df577eb07dc..2a7993064badd 100644 --- a/packages/cypress/migrations.json +++ b/packages/cypress/migrations.json @@ -35,6 +35,12 @@ "version": "19.6.0-beta.0", "description": "Update ciWebServerCommand to use previewTargetName if Vite is detected for the application.", "implementation": "./src/migrations/update-19-6-0/update-ci-webserver-for-vite" + }, + "update-19-6-0-add-e2e-ci-target-defaults": { + "cli": "nx", + "version": "19.6.0-beta.0", + "description": "Add inferred ciTargetNames to targetDefaults with dependsOn to ensure dependent application builds are scheduled before atomized tasks.", + "implementation": "./src/migrations/update-19-6-0/add-e2e-ci-target-defaults" } }, "packageJsonUpdates": { diff --git a/packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts b/packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts new file mode 100644 index 0000000000000..e82fea4f7de3e --- /dev/null +++ b/packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts @@ -0,0 +1,366 @@ +import { ProjectGraph, readNxJson, type Tree, updateNxJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import addE2eCiTargetDefaults from './add-e2e-ci-target-defaults'; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest.fn().mockImplementation(async () => { + return projectGraph; + }), +})); + +describe('add-e2e-ci-target-defaults', () => { + let tree: Tree; + let tempFs: TempFs; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tempFs = new TempFs('add-e2e-ci'); + tree.root = tempFs.tempDir; + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + tempFs.reset(); + }); + + it('should do nothing when the plugin is not registered', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = []; + updateNxJson(tree, nxJson); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should add the targetDefaults with the correct ciTargetName and buildTarget when there is one plugin', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }, + ]; + updateNxJson(tree, nxJson); + + addProject(tree, tempFs); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should add the targetDefaults with the correct ciTargetNames and buildTargets when there is more than one plugin', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + include: ['app-e2e/**'], + }, + { + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + include: ['shop-e2e/**'], + }, + ]; + updateNxJson(tree, nxJson); + + addProject(tree, tempFs); + addProject(tree, tempFs, { + buildTargetName: 'build', + ciTargetName: 'cypress:e2e-ci', + appName: 'shop', + }); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "cypress:e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + "e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should only add the targetDefaults with the correct ciTargetName and buildTargets when there is more than one plugin with only one matching multiple projects', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + include: ['cart-e2e/**'], + }, + { + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + }, + ]; + updateNxJson(tree, nxJson); + + addProject(tree, tempFs); + addProject(tree, tempFs, { + buildTargetName: 'bundle', + ciTargetName: 'cypress:e2e-ci', + appName: 'shop', + }); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "cypress:e2e-ci--**/*": { + "dependsOn": [ + "^build", + "^bundle", + ], + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should not add the targetDefaults when the ciWebServerCommand is not present', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + }, + ]; + updateNxJson(tree, nxJson); + + addProject(tree, tempFs, { + appName: 'app', + buildTargetName: 'build', + ciTargetName: 'cypress:e2e-ci', + noCi: true, + }); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "lint": { + "cache": true, + }, + } + `); + }); +}); + +function addProject( + tree: Tree, + tempFs: TempFs, + overrides: { + ciTargetName: string; + buildTargetName: string; + appName: string; + noCi?: boolean; + } = { ciTargetName: 'e2e-ci', buildTargetName: 'build', appName: 'app' } +) { + const appProjectConfig = { + name: overrides.appName, + root: overrides.appName, + sourceRoot: `${overrides.appName}/src`, + projectType: 'application', + }; + const viteConfig = `/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/${overrides.appName}', + + server: { + port: 4200, + host: 'localhost', + }, + + preview: { + port: 4300, + host: 'localhost', + }, + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + build: { + outDir: '../../dist/${overrides.appName}', + emptyOutDir: true, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, +});`; + + const e2eProjectConfig = { + name: `${overrides.appName}-e2e`, + root: `${overrides.appName}-e2e`, + sourceRoot: `${overrides.appName}-e2e/src`, + projectType: 'application', + }; + + const cypressConfig = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { + cypressDir: 'src', + bundler: 'vite', + webServerCommands: { + default: 'nx run ${overrides.appName}:serve', + production: 'nx run ${overrides.appName}:preview', + }, + ${ + !overrides.noCi + ? `ciWebServerCommand: 'nx run ${overrides.appName}:serve-static',` + : '' + } + }), + baseUrl: 'http://localhost:4200', + }, +}); +`; + + tree.write(`${overrides.appName}/vite.config.ts`, viteConfig); + tree.write( + `${overrides.appName}/project.json`, + JSON.stringify(appProjectConfig) + ); + tree.write(`${overrides.appName}-e2e/cypress.config.ts`, cypressConfig); + tree.write( + `${overrides.appName}-e2e/project.json`, + JSON.stringify(e2eProjectConfig) + ); + tempFs.createFilesSync({ + [`${overrides.appName}/vite.config.ts`]: viteConfig, + [`${overrides.appName}/project.json`]: JSON.stringify(appProjectConfig), + [`${overrides.appName}-e2e/cypress.config.ts`]: cypressConfig, + [`${overrides.appName}-e2e/project.json`]: JSON.stringify(e2eProjectConfig), + }); + + projectGraph.nodes[overrides.appName] = { + name: overrides.appName, + type: 'app', + data: { + projectType: 'application', + root: overrides.appName, + targets: { + [overrides.buildTargetName]: {}, + 'serve-static': { + options: { + buildTarget: overrides.buildTargetName, + }, + }, + }, + }, + }; + + projectGraph.nodes[`${overrides.appName}-e2e`] = { + name: `${overrides.appName}-e2e`, + type: 'app', + data: { + projectType: 'application', + root: `${overrides.appName}-e2e`, + targets: { + e2e: {}, + [overrides.ciTargetName]: {}, + }, + }, + }; +} diff --git a/packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts b/packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts new file mode 100644 index 0000000000000..3749d5c43434d --- /dev/null +++ b/packages/cypress/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts @@ -0,0 +1,102 @@ +import { + type Tree, + type CreateNodesV2, + formatFiles, + readNxJson, + createProjectGraphAsync, + parseTargetString, +} from '@nx/devkit'; +import { addE2eCiTargetDefaults as _addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; +import { LoadedNxPlugin } from 'nx/src/project-graph/plugins/internal-api'; +import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils'; +import { + ProjectConfigurationsError, + retrieveProjectConfigurations, +} from 'nx/src/devkit-internals'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { type CypressPluginOptions } from '../../plugins/plugin'; + +export default async function addE2eCiTargetDefaults(tree: Tree) { + const pluginName = '@nx/cypress/plugin'; + const graph = await createProjectGraphAsync(); + const nxJson = readNxJson(tree); + const matchingPluginRegistrations = nxJson.plugins?.filter((p) => + typeof p === 'string' ? p === pluginName : p.plugin === pluginName + ); + + if (!matchingPluginRegistrations) { + return; + } + + const { + createNodesV2, + }: { createNodesV2: CreateNodesV2 } = await import( + pluginName + ); + + for (const plugin of matchingPluginRegistrations) { + let projectConfigs: ConfigurationResult; + try { + const loadedPlugin = new LoadedNxPlugin( + { createNodesV2, name: pluginName }, + plugin + ); + projectConfigs = await retrieveProjectConfigurations( + [loadedPlugin], + tree.root, + nxJson + ); + } catch (e) { + if (e instanceof ProjectConfigurationsError) { + projectConfigs = e.partialProjectConfigurationsResult; + } else { + throw e; + } + } + + for (const configFile of projectConfigs.matchingProjectFiles) { + const configFileContents = tree.read(configFile, 'utf-8'); + if (!configFileContents.includes('ciWebServerCommand')) { + continue; + } + + const ast = tsquery.ast(configFileContents); + const CI_WEBSERVER_COMMAND_SELECTOR = + 'ObjectLiteralExpression PropertyAssignment:has(Identifier[name=ciWebServerCommand]) > StringLiteral'; + const nodes = tsquery(ast, CI_WEBSERVER_COMMAND_SELECTOR, { + visitAllChildren: true, + }); + if (!nodes.length) { + continue; + } + const ciWebServerCommand = nodes[0].getText(); + const NX_TARGET_REGEX = "(?<=nx run )[^']+"; + const matches = ciWebServerCommand.match(NX_TARGET_REGEX); + if (!matches) { + continue; + } + const targetString = matches[0]; + const { project, target, configuration } = parseTargetString( + targetString, + graph + ); + + const serveStaticTarget = graph.nodes[project].data.targets[target]; + let resolvedBuildTarget: string; + if (serveStaticTarget.dependsOn) { + resolvedBuildTarget = serveStaticTarget.dependsOn.join(','); + } else { + resolvedBuildTarget = + (configuration + ? serveStaticTarget.configurations[configuration].buildTarget + : serveStaticTarget.options.buildTarget) ?? 'build'; + } + + const buildTarget = `^${resolvedBuildTarget}`; + + await _addE2eCiTargetDefaults(tree, pluginName, buildTarget, configFile); + } + } + + await formatFiles(tree); +} diff --git a/packages/devkit/src/generators/add-build-target-defaults.ts b/packages/devkit/src/generators/add-build-target-defaults.ts deleted file mode 100644 index 6158f6e3b8cd1..0000000000000 --- a/packages/devkit/src/generators/add-build-target-defaults.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readNxJson, Tree, updateNxJson } from 'nx/src/devkit-exports'; - -export function addBuildTargetDefaults( - tree: Tree, - executorName: string, - buildTargetName = 'build' -): void { - const nxJson = readNxJson(tree); - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults[executorName] ??= { - cache: true, - dependsOn: [`^${buildTargetName}`], - inputs: - nxJson.namedInputs && 'production' in nxJson.namedInputs - ? ['production', '^production'] - : ['default', '^default'], - }; - updateNxJson(tree, nxJson); -} diff --git a/packages/devkit/src/generators/target-defaults-utils.spec.ts b/packages/devkit/src/generators/target-defaults-utils.spec.ts new file mode 100644 index 0000000000000..45769cc4645d7 --- /dev/null +++ b/packages/devkit/src/generators/target-defaults-utils.spec.ts @@ -0,0 +1,377 @@ +import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports'; +import { readNxJson, updateNxJson, type Tree } from 'nx/src/devkit-exports'; +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import { addE2eCiTargetDefaults } from './target-defaults-utils'; +describe('target-defaults-utils', () => { + describe('addE2eCiTargetDefaults', () => { + let tree: Tree; + let tempFs: TempFs; + beforeEach(() => { + tempFs = new TempFs('target-defaults-utils'); + tree = createTreeWithEmptyWorkspace(); + tree.root = tempFs.tempDir; + }); + + afterEach(() => { + tempFs.cleanup(); + jest.resetModules(); + }); + + it('should add e2e-ci--**/* target default for e2e plugin for specified build target when it does not exist', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults['e2e-ci--**/*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); + }); + + it('should update existing e2e-ci--**/* target default for e2e plugin for specified build target when it does not exist in dependsOn', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['e2e-ci--**/*'] = { + dependsOn: ['^build'], + }; + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build-base', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults['e2e-ci--**/*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + "^build-base", + ], + } + `); + }); + + it('should read the ciTargetName and add a new entry when it does not exist', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + }); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['e2e-ci--**/*'] = { + dependsOn: ['^build'], + }; + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build-base', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults['e2e-ci--**/*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); + expect(newNxJson.targetDefaults['cypress:e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build-base", + ], + } + `); + }); + + it('should not add additional e2e-ci--**/* target default for e2e plugin when it already exists with build target', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['e2e-ci--**/*'] = { + dependsOn: ['^build'], + }; + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should do nothing when there are no nxJson.plugins does not exist', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = undefined; + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should do nothing when there are nxJson.plugins but e2e plugin is not registered', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/playwright/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should choose the correct plugin when there are includes', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + include: ['libs/**'], + }); + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + include: ['apps/**'], + }); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults['cypress:e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); + }); + + it('should choose the correct plugin when there are excludes', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + exclude: ['apps/**'], + }); + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + exclude: ['libs/**'], + }); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults['cypress:e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); + }); + + it('should use the default name when the plugin registration is a string', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push('@nx/cypress/plugin'); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + '^build', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + const newNxJson = readNxJson(tree); + expect(newNxJson.targetDefaults['e2e-ci--**/*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); + }); + }); +}); diff --git a/packages/devkit/src/generators/target-defaults-utils.ts b/packages/devkit/src/generators/target-defaults-utils.ts new file mode 100644 index 0000000000000..d2fd347ccafdd --- /dev/null +++ b/packages/devkit/src/generators/target-defaults-utils.ts @@ -0,0 +1,98 @@ +import { + type CreateNodes, + type CreateNodesV2, + type PluginConfiguration, + type Tree, + readNxJson, + updateNxJson, +} from 'nx/src/devkit-exports'; +import { findMatchingConfigFiles } from 'nx/src/devkit-internals'; + +export function addBuildTargetDefaults( + tree: Tree, + executorName: string, + buildTargetName = 'build' +): void { + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults[executorName] ??= { + cache: true, + dependsOn: [`^${buildTargetName}`], + inputs: + nxJson.namedInputs && 'production' in nxJson.namedInputs + ? ['production', '^production'] + : ['default', '^default'], + }; + updateNxJson(tree, nxJson); +} + +export async function addE2eCiTargetDefaults( + tree: Tree, + e2ePlugin: string, + buildTarget: string, + pathToE2EConfigFile: string +): Promise { + const nxJson = readNxJson(tree); + if (!nxJson.plugins) { + return; + } + + const e2ePluginRegistrations = nxJson.plugins.filter((p) => + typeof p === 'string' ? p === e2ePlugin : p.plugin === e2ePlugin + ); + if (!e2ePluginRegistrations.length) { + return; + } + + const resolvedE2ePlugin: { + createNodes?: CreateNodes; + createNodesV2?: CreateNodesV2; + } = await import(e2ePlugin); + const e2ePluginGlob = + resolvedE2ePlugin.createNodesV2?.[0] ?? resolvedE2ePlugin.createNodes?.[0]; + + let foundPluginForApplication: PluginConfiguration; + for (let i = 0; i < e2ePluginRegistrations.length; i++) { + let candidatePluginForApplication = e2ePluginRegistrations[i]; + if (typeof candidatePluginForApplication === 'string') { + foundPluginForApplication = candidatePluginForApplication; + break; + } + + const matchingConfigFiles = findMatchingConfigFiles( + [pathToE2EConfigFile], + e2ePluginGlob, + candidatePluginForApplication.include, + candidatePluginForApplication.exclude + ); + + if (matchingConfigFiles.length) { + foundPluginForApplication = candidatePluginForApplication; + break; + } + } + + if (!foundPluginForApplication) { + return; + } + + const ciTargetName = + typeof foundPluginForApplication === 'string' + ? 'e2e-ci' + : (foundPluginForApplication.options as any)?.ciTargetName ?? 'e2e-ci'; + + const ciTargetNameGlob = `${ciTargetName}--**/*`; + nxJson.targetDefaults ??= {}; + const e2eCiTargetDefaults = nxJson.targetDefaults[ciTargetNameGlob]; + if (!e2eCiTargetDefaults) { + nxJson.targetDefaults[ciTargetNameGlob] = { + dependsOn: [buildTarget], + }; + } else { + e2eCiTargetDefaults.dependsOn ??= []; + if (!e2eCiTargetDefaults.dependsOn.includes(buildTarget)) { + e2eCiTargetDefaults.dependsOn.push(buildTarget); + } + } + updateNxJson(tree, nxJson); +} diff --git a/packages/devkit/src/utils/find-plugin-for-config-file.spec.ts b/packages/devkit/src/utils/find-plugin-for-config-file.spec.ts new file mode 100644 index 0000000000000..941fb356dbcf9 --- /dev/null +++ b/packages/devkit/src/utils/find-plugin-for-config-file.spec.ts @@ -0,0 +1,173 @@ +import { type Tree, readNxJson, updateNxJson } from 'nx/src/devkit-exports'; +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports'; +import { findPluginForConfigFile } from './find-plugin-for-config-file'; + +describe('find-plugin-for-config-file', () => { + let tree: Tree; + let tempFs: TempFs; + beforeEach(() => { + tempFs = new TempFs('target-defaults-utils'); + tree = createTreeWithEmptyWorkspace(); + tree.root = tempFs.tempDir; + }); + + afterEach(() => { + tempFs.cleanup(); + jest.resetModules(); + }); + + it('should return the plugin when its registered as just a string', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push('@nx/cypress/plugin'); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + const plugin = await findPluginForConfigFile( + tree, + '@nx/cypress/plugin', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + expect(plugin).toBeTruthy(); + expect(plugin).toEqual('@nx/cypress/plugin'); + }); + + it('should return the plugin when it does not have an include or exclude', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + const plugin = await findPluginForConfigFile( + tree, + '@nx/cypress/plugin', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + expect(plugin).toBeTruthy(); + expect(plugin).toMatchInlineSnapshot(` + { + "options": { + "ciTargetName": "e2e-ci", + "targetName": "e2e", + }, + "plugin": "@nx/cypress/plugin", + } + `); + }); + + it('should return the plugin when it the includes finds the config file', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + include: ['libs/**'], + }); + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + include: ['apps/**'], + }); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + const plugin = await findPluginForConfigFile( + tree, + '@nx/cypress/plugin', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + expect(plugin).toBeTruthy(); + expect(plugin).toMatchInlineSnapshot(` + { + "include": [ + "apps/**", + ], + "options": { + "ciTargetName": "cypress:e2e-ci", + "targetName": "e2e", + }, + "plugin": "@nx/cypress/plugin", + } + `); + }); + + it('should return a valid plugin when it the excludes does not include the config file', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'cypress:e2e-ci', + }, + exclude: ['apps/**'], + }); + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + exclude: ['libs/**'], + }); + updateNxJson(tree, nxJson); + + tree.write('apps/myapp-e2e/cypress.config.ts', ''); + await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', ''); + + // ACT + const plugin = await findPluginForConfigFile( + tree, + '@nx/cypress/plugin', + 'apps/myapp-e2e/cypress.config.ts' + ); + + // ASSERT + expect(plugin).toBeTruthy(); + expect(plugin).toMatchInlineSnapshot(` + { + "exclude": [ + "libs/**", + ], + "options": { + "ciTargetName": "e2e-ci", + "targetName": "e2e", + }, + "plugin": "@nx/cypress/plugin", + } + `); + }); +}); diff --git a/packages/devkit/src/utils/find-plugin-for-config-file.ts b/packages/devkit/src/utils/find-plugin-for-config-file.ts new file mode 100644 index 0000000000000..7098f07bab8b1 --- /dev/null +++ b/packages/devkit/src/utils/find-plugin-for-config-file.ts @@ -0,0 +1,50 @@ +import { + type Tree, + type PluginConfiguration, + readNxJson, + CreateNodes, + CreateNodesV2, +} from 'nx/src/devkit-exports'; +import { findMatchingConfigFiles } from 'nx/src/devkit-internals'; +export async function findPluginForConfigFile( + tree: Tree, + pluginName: string, + pathToConfigFile: string +): Promise { + const nxJson = readNxJson(tree); + if (!nxJson.plugins) { + return; + } + + const pluginRegistrations: PluginConfiguration[] = nxJson.plugins.filter( + (p) => (typeof p === 'string' ? p === pluginName : p.plugin === pluginName) + ); + + for (const plugin of pluginRegistrations) { + if (typeof plugin === 'string') { + return plugin; + } + + if (!plugin.include && !plugin.exclude) { + return plugin; + } + + if (plugin.include || plugin.exclude) { + const resolvedPlugin: { + createNodes?: CreateNodes; + createNodesV2?: CreateNodesV2; + } = await import(pluginName); + const pluginGlob = + resolvedPlugin.createNodesV2?.[0] ?? resolvedPlugin.createNodes?.[0]; + const matchingConfigFile = findMatchingConfigFiles( + [pathToConfigFile], + pluginGlob, + plugin.include, + plugin.exclude + ); + if (matchingConfigFile.length) { + return plugin; + } + } + } +} diff --git a/packages/esbuild/src/generators/configuration/configuration.ts b/packages/esbuild/src/generators/configuration/configuration.ts index 14d3aa2a5abd3..f838f143ded4a 100644 --- a/packages/esbuild/src/generators/configuration/configuration.ts +++ b/packages/esbuild/src/generators/configuration/configuration.ts @@ -12,7 +12,7 @@ import { getImportPath } from '@nx/js/src/utils/get-import-path'; import { esbuildInitGenerator } from '../init/init'; import { EsBuildExecutorOptions } from '../../executors/esbuild/schema'; import { EsBuildProjectSchema } from './schema'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function configurationGenerator( tree: Tree, diff --git a/packages/expo/src/generators/application/application.spec.ts b/packages/expo/src/generators/application/application.spec.ts index 3b3b574eb10d2..087b6ee6c8cca 100644 --- a/packages/expo/src/generators/application/application.spec.ts +++ b/packages/expo/src/generators/application/application.spec.ts @@ -3,6 +3,7 @@ import 'nx/src/internal-testing-utils/mock-project-graph'; import { getProjects, readJson, + readNxJson, readProjectConfiguration, Tree, } from '@nx/devkit'; @@ -282,4 +283,56 @@ describe('app', () => { }); }); }); + + describe('cypress', () => { + it('should create e2e app with e2e-ci targetDefaults', async () => { + await expoApplicationGenerator(appTree, { + name: 'my-app', + directory: 'my-dir', + linter: Linter.EsLint, + e2eTestRunner: 'cypress', + js: false, + skipFormat: false, + unitTestRunner: 'none', + projectNameAndRootFormat: 'as-provided', + addPlugin: true, + }); + + // ASSERT + const nxJson = readNxJson(appTree); + expect(nxJson.targetDefaults['e2e-ci--**/*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^export", + ], + } + `); + }); + }); + + describe('playwright', () => { + it('should create e2e app with e2e-ci targetDefaults', async () => { + await expoApplicationGenerator(appTree, { + name: 'my-app', + directory: 'my-dir', + linter: Linter.EsLint, + e2eTestRunner: 'playwright', + js: false, + skipFormat: false, + unitTestRunner: 'none', + projectNameAndRootFormat: 'as-provided', + addPlugin: true, + }); + + // ASSERT + const nxJson = readNxJson(appTree); + expect(nxJson.targetDefaults['e2e-ci--**/*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^export", + ], + } + `); + }); + }); }); diff --git a/packages/expo/src/generators/application/lib/add-e2e.ts b/packages/expo/src/generators/application/lib/add-e2e.ts index 45671e2fb90f3..9b059213f80c2 100644 --- a/packages/expo/src/generators/application/lib/add-e2e.ts +++ b/packages/expo/src/generators/application/lib/add-e2e.ts @@ -4,12 +4,15 @@ import { ensurePackage, getPackageManagerCommand, joinPathFragments, + readNxJson, } from '@nx/devkit'; import { webStaticServeGenerator } from '@nx/web'; import { nxVersion } from '../../../utils/versions'; import { hasExpoPlugin } from '../../../utils/has-expo-plugin'; import { NormalizedSchema } from './normalize-options'; +import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; +import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; export async function addE2e( tree: Tree, @@ -18,8 +21,7 @@ export async function addE2e( const hasPlugin = hasExpoPlugin(tree); switch (options.e2eTestRunner) { case 'cypress': { - const hasNxExpoPlugin = hasExpoPlugin(tree); - if (!hasNxExpoPlugin) { + if (!hasPlugin) { await webStaticServeGenerator(tree, { buildTarget: `${options.projectName}:export`, targetName: 'serve-static', @@ -39,7 +41,7 @@ export async function addE2e( tags: [], }); - return await configurationGenerator(tree, { + const e2eTask = await configurationGenerator(tree, { ...options, project: options.e2eProjectName, directory: 'src', @@ -49,12 +51,46 @@ export async function addE2e( devServerTarget: `${options.projectName}:${options.e2eWebServerTarget}`, port: options.e2ePort, baseUrl: options.e2eWebServerAddress, - ciWebServerCommand: hasNxExpoPlugin + ciWebServerCommand: hasPlugin ? `nx run ${options.projectName}:serve-static` : undefined, jsx: true, rootProject: options.rootProject, }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ) + ) { + let buildTarget = '^export'; + if (hasPlugin) { + const matchingExpoPlugin = await findPluginForConfigFile( + tree, + '@nx/expo/plugin', + joinPathFragments(options.appProjectRoot, 'app.json') + ); + if (matchingExpoPlugin && typeof matchingExpoPlugin !== 'string') { + buildTarget = `^${ + (matchingExpoPlugin.options as any)?.exportTargetName ?? 'export' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + buildTarget, + joinPathFragments( + options.e2eProjectRoot, + `cypress.config.${options.js ? 'js' : 'ts'}` + ) + ); + } + + return e2eTask; } case 'playwright': { const { configurationGenerator } = ensurePackage< @@ -67,7 +103,8 @@ export async function addE2e( targets: {}, implicitDependencies: [options.projectName], }); - return configurationGenerator(tree, { + + const e2eTask = await configurationGenerator(tree, { project: options.e2eProjectName, skipFormat: true, skipPackageJson: options.skipPackageJson, @@ -80,7 +117,39 @@ export async function addE2e( } ${options.name}`, webServerAddress: options.e2eWebServerAddress, rootProject: options.rootProject, + addPlugin: options.addPlugin, }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ) + ) { + let buildTarget = '^export'; + if (hasPlugin) { + const matchingExpoPlugin = await findPluginForConfigFile( + tree, + '@nx/expo/plugin', + joinPathFragments(options.appProjectRoot, 'app.json') + ); + if (matchingExpoPlugin && typeof matchingExpoPlugin !== 'string') { + buildTarget = `^${ + (matchingExpoPlugin.options as any)?.exportTargetName ?? 'export' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/playwright/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `playwright.config.ts`) + ); + } + + return e2eTask; } case 'detox': const { detoxApplicationGenerator } = ensurePackage< diff --git a/packages/expo/src/generators/application/lib/add-project.ts b/packages/expo/src/generators/application/lib/add-project.ts index 1f5e725474464..1f79ea08c95c0 100644 --- a/packages/expo/src/generators/application/lib/add-project.ts +++ b/packages/expo/src/generators/application/lib/add-project.ts @@ -8,7 +8,7 @@ import { import { hasExpoPlugin } from '../../../utils/has-expo-plugin'; import { NormalizedSchema } from './normalize-options'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export function addProject(host: Tree, options: NormalizedSchema) { const nxJson = readNxJson(host); diff --git a/packages/expo/src/generators/library/library.ts b/packages/expo/src/generators/library/library.ts index 61c3eb58b9696..7751b2e641235 100644 --- a/packages/expo/src/generators/library/library.ts +++ b/packages/expo/src/generators/library/library.ts @@ -32,7 +32,7 @@ import { NormalizedSchema, normalizeOptions } from './lib/normalize-options'; import { Schema } from './schema'; import { ensureDependencies } from '../../utils/ensure-dependencies'; import { initRootBabelConfig } from '../../utils/init-root-babel-config'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command'; export async function expoLibraryGenerator( diff --git a/packages/js/src/generators/library/library.ts b/packages/js/src/generators/library/library.ts index 6ec990008bcfb..4efc7a7e3d9ab 100644 --- a/packages/js/src/generators/library/library.ts +++ b/packages/js/src/generators/library/library.ts @@ -25,7 +25,7 @@ import { type ProjectNameAndRootOptions, } from '@nx/devkit/src/generators/project-name-and-root-utils'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command'; import { findMatchingProjects } from 'nx/src/utils/find-matching-projects'; import { type PackageJson } from 'nx/src/utils/package-json'; diff --git a/packages/js/src/generators/setup-build/generator.ts b/packages/js/src/generators/setup-build/generator.ts index b83e251c1b77e..c39416c3e31c0 100644 --- a/packages/js/src/generators/setup-build/generator.ts +++ b/packages/js/src/generators/setup-build/generator.ts @@ -12,7 +12,7 @@ import { addSwcConfig } from '../../utils/swc/add-swc-config'; import { addSwcDependencies } from '../../utils/swc/add-swc-dependencies'; import { nxVersion } from '../../utils/versions'; import { SetupBuildGeneratorSchema } from './schema'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function setupBuildGenerator( tree: Tree, diff --git a/packages/next/src/generators/application/application.spec.ts b/packages/next/src/generators/application/application.spec.ts index 2fb1f976f479f..fa54ed0b95d6d 100644 --- a/packages/next/src/generators/application/application.spec.ts +++ b/packages/next/src/generators/application/application.spec.ts @@ -2,6 +2,7 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { getProjects, readJson, + readNxJson, readProjectConfiguration, Tree, } from '@nx/devkit'; @@ -359,28 +360,9 @@ describe('app', () => { const indexContent = tree.read(`${name}/src/app/page.tsx`, 'utf-8'); expect(indexContent).not.toContain(`import styles from './page.module`); expect(indexContent).toContain(`import styled from '@emotion/styled'`); - expect(tree.read(`${name}/src/app/layout.tsx`, 'utf-8')) - .toMatchInlineSnapshot(` - "import './global.css'; - - export const metadata = { - title: 'Welcome to ${name}', - description: 'Generated by create-nx-workspace', - }; - - export default function RootLayout({ - children, - }: { - children: React.ReactNode; - }) { - return ( - - {children} - - ); - } - " - `); + expect( + tree.read(`${name}/src/app/layout.tsx`, 'utf-8') + ).toMatchInlineSnapshot(``); }); it('should add jsxImportSource in tsconfig.json', async () => { @@ -559,6 +541,50 @@ describe('app', () => { }); }); + describe('--e2e-test-runner cypress', () => { + it('should generate e2e-ci targetDefaults', async () => { + const name = uniq(); + + await applicationGenerator(tree, { + name, + style: 'css', + e2eTestRunner: 'cypress', + projectNameAndRootFormat: 'as-provided', + addPlugin: true, + }); + expect(readNxJson(tree).targetDefaults['e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); + }); + }); + + describe('--e2e-test-runner playwright', () => { + it('should generate e2e-ci targetDefaults', async () => { + const name = uniq(); + + await applicationGenerator(tree, { + name, + style: 'css', + e2eTestRunner: 'playwright', + projectNameAndRootFormat: 'as-provided', + addPlugin: true, + }); + expect(readNxJson(tree).targetDefaults['e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); + }); + }); + it('should generate functional components by default', async () => { const name = uniq(); diff --git a/packages/next/src/generators/application/lib/add-e2e.ts b/packages/next/src/generators/application/lib/add-e2e.ts index 50290052424ca..11cb0eb3f98b6 100644 --- a/packages/next/src/generators/application/lib/add-e2e.ts +++ b/packages/next/src/generators/application/lib/add-e2e.ts @@ -11,6 +11,8 @@ import { Linter } from '@nx/eslint'; import { nxVersion } from '../../../utils/versions'; import { NormalizedSchema } from './normalize-options'; import { webStaticServeGenerator } from '@nx/web'; +import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; +import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function addE2e(host: Tree, options: NormalizedSchema) { const nxJson = readNxJson(host); @@ -42,7 +44,7 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { implicitDependencies: [options.projectName], }); - return configurationGenerator(host, { + const e2eTask = await configurationGenerator(host, { ...options, linter: Linter.EsLint, project: options.e2eProjectName, @@ -60,6 +62,40 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { ? `nx run ${options.projectName}:serve-static` : undefined, }); + + if ( + options.addPlugin || + readNxJson(host).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasPlugin) { + const matchingPlugin = await findPluginForConfigFile( + host, + '@nx/next/plugin', + joinPathFragments(options.appProjectRoot, 'next.config.js') + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + host, + '@nx/cypress/plugin', + buildTarget, + joinPathFragments( + options.e2eProjectRoot, + `cypress.config.${options.js ? 'js' : 'ts'}` + ) + ); + } + + return e2eTask; } else if (options.e2eTestRunner === 'playwright') { const { configurationGenerator } = ensurePackage< typeof import('@nx/playwright') @@ -71,7 +107,7 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { tags: [], implicitDependencies: [options.projectName], }); - return configurationGenerator(host, { + const e2eTask = await configurationGenerator(host, { rootProject: options.rootProject, project: options.e2eProjectName, skipFormat: true, @@ -86,6 +122,37 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { } ${options.projectName}`, addPlugin: options.addPlugin, }); + + if ( + options.addPlugin || + readNxJson(host).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasPlugin) { + const matchingPlugin = await findPluginForConfigFile( + host, + '@nx/next/plugin', + joinPathFragments(options.appProjectRoot, 'next.config.js') + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + host, + '@nx/playwright/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `playwright.config.ts`) + ); + } + + return e2eTask; } return () => {}; } diff --git a/packages/next/src/generators/application/lib/add-project.ts b/packages/next/src/generators/application/lib/add-project.ts index 84ad72664cb2f..947d9ce6fbe23 100644 --- a/packages/next/src/generators/application/lib/add-project.ts +++ b/packages/next/src/generators/application/lib/add-project.ts @@ -5,7 +5,7 @@ import { readNxJson, Tree, } from '@nx/devkit'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export function addProject(host: Tree, options: NormalizedSchema) { const targets: Record = {}; diff --git a/packages/node/src/generators/application/application.ts b/packages/node/src/generators/application/application.ts index 9ad70f32eafe0..a6ec2ce256ea3 100644 --- a/packages/node/src/generators/application/application.ts +++ b/packages/node/src/generators/application/application.ts @@ -48,7 +48,7 @@ import { initGenerator } from '../init/init'; import { setupDockerGenerator } from '../setup-docker/setup-docker'; import { Schema } from './schema'; import { hasWebpackPlugin } from '../../utils/has-webpack-plugin'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command'; export interface NormalizedSchema extends Schema { diff --git a/packages/node/src/generators/library/library.ts b/packages/node/src/generators/library/library.ts index 9499236ced506..2ecec34cf3203 100644 --- a/packages/node/src/generators/library/library.ts +++ b/packages/node/src/generators/library/library.ts @@ -22,7 +22,7 @@ import { join } from 'path'; import { tslibVersion, typesNodeVersion } from '../../utils/versions'; import { initGenerator } from '../init/init'; import { Schema } from './schema'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export interface NormalizedSchema extends Schema { fileName: string; diff --git a/packages/nuxt/src/generators/application/__snapshots__/application.spec.ts.snap b/packages/nuxt/src/generators/application/__snapshots__/application.spec.ts.snap index d27094dbc898b..2b212d22ab51b 100644 --- a/packages/nuxt/src/generators/application/__snapshots__/application.spec.ts.snap +++ b/packages/nuxt/src/generators/application/__snapshots__/application.spec.ts.snap @@ -10,6 +10,14 @@ exports[`app generated files content - as-provided - my-app general application .cache" `; +exports[`app generated files content - as-provided - my-app general application should add the nuxt and vitest plugins 1`] = ` +{ + "dependsOn": [ + "^build-static", + ], +} +`; + exports[`app generated files content - as-provided - my-app general application should configure eslint correctly 1`] = ` "{ "extends": ["@nuxt/eslint-config", "../.eslintrc.json"], @@ -341,6 +349,14 @@ exports[`app generated files content - as-provided - myApp general application s .cache" `; +exports[`app generated files content - as-provided - myApp general application should add the nuxt and vitest plugins 1`] = ` +{ + "dependsOn": [ + "^build-static", + ], +} +`; + exports[`app generated files content - as-provided - myApp general application should configure eslint correctly 1`] = ` "{ "extends": ["@nuxt/eslint-config", "../.eslintrc.json"], diff --git a/packages/nuxt/src/generators/application/application.spec.ts b/packages/nuxt/src/generators/application/application.spec.ts index d03bdbe32f7a5..cc7602937ab97 100644 --- a/packages/nuxt/src/generators/application/application.spec.ts +++ b/packages/nuxt/src/generators/application/application.spec.ts @@ -96,6 +96,7 @@ describe('app', () => { nxJson.plugins.find((p) => p.plugin === '@nx/vite/plugin') ) ); + expect(nxJson.targetDefaults['e2e-ci--**/*']).toMatchSnapshot(); }); }); diff --git a/packages/nuxt/src/generators/application/lib/add-e2e.ts b/packages/nuxt/src/generators/application/lib/add-e2e.ts index 35fb27fc83484..3c7bc78fe1082 100644 --- a/packages/nuxt/src/generators/application/lib/add-e2e.ts +++ b/packages/nuxt/src/generators/application/lib/add-e2e.ts @@ -7,6 +7,8 @@ import { } from '@nx/devkit'; import { nxVersion } from '../../../utils/versions'; import { NormalizedSchema } from '../schema'; +import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; +import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function addE2e(host: Tree, options: NormalizedSchema) { if (options.e2eTestRunner === 'cypress') { @@ -21,7 +23,7 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { tags: [], implicitDependencies: [options.projectName], }); - return await configurationGenerator(host, { + const e2eTask = await configurationGenerator(host, { ...options, project: options.e2eProjectName, directory: 'src', @@ -38,6 +40,33 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { jsx: true, addPlugin: true, }); + + let buildTarget = '^build-static'; + const matchingPlugin = await findPluginForConfigFile( + host, + '@nx/nuxt/plugin', + joinPathFragments( + options.appProjectRoot, + `nuxt.config.${options.js ? 'js' : 'ts'}` + ) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildStaticTargetName ?? 'build-static' + }`; + } + + await addE2eCiTargetDefaults( + host, + '@nx/cypress/plugin', + buildTarget, + joinPathFragments( + options.e2eProjectRoot, + `cypress.config.${options.js ? 'js' : 'ts'}` + ) + ); + + return e2eTask; } else if (options.e2eTestRunner === 'playwright') { const { configurationGenerator } = ensurePackage< typeof import('@nx/playwright') @@ -48,7 +77,7 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { targets: {}, implicitDependencies: [options.projectName], }); - return configurationGenerator(host, { + const e2eTask = await configurationGenerator(host, { project: options.e2eProjectName, skipFormat: true, skipPackageJson: options.skipPackageJson, @@ -62,6 +91,30 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { } ${options.projectName}`, addPlugin: true, }); + + let buildTarget = '^build-static'; + const matchingPlugin = await findPluginForConfigFile( + host, + '@nx/nuxt/plugin', + joinPathFragments( + options.appProjectRoot, + `nuxt.config.${options.js ? 'js' : 'ts'}` + ) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildStaticTargetName ?? 'build-static' + }`; + } + + await addE2eCiTargetDefaults( + host, + '@nx/playwright/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `playwright.config.ts`) + ); + + return e2eTask; } return () => {}; } diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts index 72be433b4c674..d380df1298833 100644 --- a/packages/nx/src/devkit-internals.ts +++ b/packages/nx/src/devkit-internals.ts @@ -8,11 +8,11 @@ export { getExecutorInformation } from './command-line/run/executor-utils'; export { readNxJson as readNxJsonFromDisk } from './config/nx-json'; export { calculateDefaultProjectName } from './config/calculate-default-project-name'; export { retrieveProjectConfigurationsWithAngularProjects } from './project-graph/utils/retrieve-workspace-files'; +export { mergeTargetConfigurations } from './project-graph/utils/project-configuration-utils'; export { - mergeTargetConfigurations, + readProjectConfigurationsFromRootMap, findMatchingConfigFiles, } from './project-graph/utils/project-configuration-utils'; -export { readProjectConfigurationsFromRootMap } from './project-graph/utils/project-configuration-utils'; export { splitTarget } from './utils/split-target'; export { combineOptionsForExecutor } from './utils/params'; export { sortObjectByKeys } from './utils/object-sort'; diff --git a/packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts b/packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts new file mode 100644 index 0000000000000..ba65c8f32bf68 --- /dev/null +++ b/packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.spec.ts @@ -0,0 +1,333 @@ +import { ProjectGraph, readNxJson, type Tree, updateNxJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import addE2eCiTargetDefaults from './add-e2e-ci-target-defaults'; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest.fn().mockImplementation(async () => { + return projectGraph; + }), +})); + +describe('add-e2e-ci-target-defaults', () => { + let tree: Tree; + let tempFs: TempFs; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tempFs = new TempFs('add-e2e-ci'); + tree.root = tempFs.tempDir; + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + tempFs.reset(); + }); + + it('should do nothing when the plugin is not registered', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = []; + updateNxJson(tree, nxJson); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should add the targetDefaults with the correct ciTargetName and buildTarget when there is one plugin', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/playwright/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }, + ]; + updateNxJson(tree, nxJson); + + addProject(tree, tempFs); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + "lint": { + "cache": true, + }, + } + `); + }); + + it('should add the targetDefaults with the correct ciTargetNames and buildTargets when there is more than one plugin', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/playwright/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + include: ['app-e2e/**'], + }, + { + plugin: '@nx/playwright/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'playwright:e2e-ci', + }, + include: ['shop-e2e/**'], + }, + ]; + updateNxJson(tree, nxJson); + + addProject(tree, tempFs); + addProject(tree, tempFs, { + buildTargetName: 'build', + ciTargetName: 'playwright:e2e-ci', + appName: 'shop', + }); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + "lint": { + "cache": true, + }, + "playwright:e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + } + `); + }); + + it('should only add the targetDefaults with the correct ciTargetName and buildTargets when there is more than one plugin with only one matching multiple projects', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/playwright/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + include: ['cart-e2e/**'], + }, + { + plugin: '@nx/playwright/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'playwright:e2e-ci', + }, + }, + ]; + updateNxJson(tree, nxJson); + + addProject(tree, tempFs); + addProject(tree, tempFs, { + buildTargetName: 'bundle', + ciTargetName: 'playwright:e2e-ci', + appName: 'shop', + }); + + // ACT + await addE2eCiTargetDefaults(tree); + + // ASSERT + expect(readNxJson(tree).targetDefaults).toMatchInlineSnapshot(` + { + "build": { + "cache": true, + }, + "lint": { + "cache": true, + }, + "playwright:e2e-ci--**/*": { + "dependsOn": [ + "^build", + "^bundle", + ], + }, + } + `); + }); +}); + +function addProject( + tree: Tree, + tempFs: TempFs, + overrides: { + ciTargetName: string; + buildTargetName: string; + appName: string; + noCi?: boolean; + } = { ciTargetName: 'e2e-ci', buildTargetName: 'build', appName: 'app' } +) { + const appProjectConfig = { + name: overrides.appName, + root: overrides.appName, + sourceRoot: `${overrides.appName}/src`, + projectType: 'application', + }; + const viteConfig = `/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/${overrides.appName}', + + server: { + port: 4200, + host: 'localhost', + }, + + preview: { + port: 4300, + host: 'localhost', + }, + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + build: { + outDir: '../../dist/${overrides.appName}', + emptyOutDir: true, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, +});`; + + const e2eProjectConfig = { + name: `${overrides.appName}-e2e`, + root: `${overrides.appName}-e2e`, + sourceRoot: `${overrides.appName}-e2e/src`, + projectType: 'application', + }; + + const playwrightConfig = `import { defineConfig, devices } from '@playwright/test'; +import { nxE2EPreset } from '@nx/playwright/preset'; + +import { workspaceRoot } from '@nx/devkit'; + +const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; + +export default defineConfig({ + ...nxE2EPreset(__filename, { testDir: './src' }), + use: { + baseURL, + trace: 'on-first-retry', + }, + webServer: { + command: 'npx nx run ${overrides.appName}:serve-static', + url: 'http://localhost:4200', + reuseExistingServer: !process.env.CI, + cwd: workspaceRoot, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +});`; + + tree.write(`${overrides.appName}/vite.config.ts`, viteConfig); + tree.write( + `${overrides.appName}/project.json`, + JSON.stringify(appProjectConfig) + ); + tree.write(`${overrides.appName}-e2e/playwright.config.ts`, playwrightConfig); + tree.write( + `${overrides.appName}-e2e/project.json`, + JSON.stringify(e2eProjectConfig) + ); + tempFs.createFilesSync({ + [`${overrides.appName}/vite.config.ts`]: viteConfig, + [`${overrides.appName}/project.json`]: JSON.stringify(appProjectConfig), + [`${overrides.appName}-e2e/playwright.config.ts`]: playwrightConfig, + [`${overrides.appName}-e2e/project.json`]: JSON.stringify(e2eProjectConfig), + }); + + projectGraph.nodes[overrides.appName] = { + name: overrides.appName, + type: 'app', + data: { + projectType: 'application', + root: overrides.appName, + targets: { + [overrides.buildTargetName]: {}, + 'serve-static': { + dependsOn: [overrides.buildTargetName], + options: { + buildTarget: overrides.buildTargetName, + }, + }, + }, + }, + }; + + projectGraph.nodes[`${overrides.appName}-e2e`] = { + name: `${overrides.appName}-e2e`, + type: 'app', + data: { + projectType: 'application', + root: `${overrides.appName}-e2e`, + targets: { + e2e: {}, + [overrides.ciTargetName]: {}, + }, + }, + }; +} diff --git a/packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts b/packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts new file mode 100644 index 0000000000000..d108c5a03731e --- /dev/null +++ b/packages/playwright/src/migrations/update-19-6-0/add-e2e-ci-target-defaults.ts @@ -0,0 +1,116 @@ +import { + type Tree, + type CreateNodesV2, + formatFiles, + readNxJson, + createProjectGraphAsync, + parseTargetString, +} from '@nx/devkit'; +import { addE2eCiTargetDefaults as _addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; +import { LoadedNxPlugin } from 'nx/src/project-graph/plugins/internal-api'; +import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils'; +import { + ProjectConfigurationsError, + retrieveProjectConfigurations, +} from 'nx/src/devkit-internals'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { type PlaywrightPluginOptions } from '../../plugins/plugin'; + +export default async function addE2eCiTargetDefaults(tree: Tree) { + const pluginName = '@nx/playwright/plugin'; + const graph = await createProjectGraphAsync(); + const nxJson = readNxJson(tree); + const matchingPluginRegistrations = nxJson.plugins?.filter((p) => + typeof p === 'string' ? p === pluginName : p.plugin === pluginName + ); + + if (!matchingPluginRegistrations) { + return; + } + + const { + createNodesV2, + }: { createNodesV2: CreateNodesV2 } = await import( + pluginName + ); + + for (const plugin of matchingPluginRegistrations) { + let projectConfigs: ConfigurationResult; + try { + const loadedPlugin = new LoadedNxPlugin( + { createNodesV2, name: pluginName }, + plugin + ); + projectConfigs = await retrieveProjectConfigurations( + [loadedPlugin], + tree.root, + nxJson + ); + } catch (e) { + if (e instanceof ProjectConfigurationsError) { + projectConfigs = e.partialProjectConfigurationsResult; + } else { + throw e; + } + } + + for (const configFile of projectConfigs.matchingProjectFiles) { + const configFileContents = tree.read(configFile, 'utf-8'); + + const ast = tsquery.ast(configFileContents); + const CI_WEBSERVER_COMMAND_SELECTOR = + 'PropertyAssignment:has(Identifier[name=webServer]) PropertyAssignment:has(Identifier[name=command]) > StringLiteral'; + const nodes = tsquery(ast, CI_WEBSERVER_COMMAND_SELECTOR, { + visitAllChildren: true, + }); + if (!nodes.length) { + continue; + } + const ciWebServerCommand = nodes[0].getText(); + let serveStaticProject: string; + let serveStaticTarget: string; + let serveStaticConfiguration: string; + if (ciWebServerCommand.includes('nx run')) { + const NX_TARGET_REGEX = "(?<=nx run )[^']+"; + const matches = ciWebServerCommand.match(NX_TARGET_REGEX); + if (!matches) { + continue; + } + const targetString = matches[0]; + const { project, target, configuration } = parseTargetString( + targetString, + graph + ); + serveStaticProject = project; + serveStaticTarget = target; + serveStaticConfiguration = configuration; + } else { + const NX_PROJECT_REGEX = 'nx\\s+([^ ]+)\\s+([^ ]+)'; + const matches = ciWebServerCommand.match(NX_PROJECT_REGEX); + if (!matches) { + return; + } + serveStaticTarget = matches[1]; + serveStaticProject = matches[2]; + } + + const resolvedServeStaticTarget = + graph.nodes[serveStaticProject].data.targets[serveStaticTarget]; + + let resolvedBuildTarget: string; + if (resolvedServeStaticTarget.dependsOn) { + resolvedBuildTarget = resolvedServeStaticTarget.dependsOn.join(','); + } else { + resolvedBuildTarget = + (serveStaticConfiguration + ? resolvedServeStaticTarget.configurations[serveStaticConfiguration] + .buildTarget + : resolvedServeStaticTarget.options.buildTarget) ?? 'build'; + } + + const buildTarget = `^${resolvedBuildTarget}`; + + await _addE2eCiTargetDefaults(tree, pluginName, buildTarget, configFile); + } + } +} diff --git a/packages/playwright/src/migrations/update-19-6-0/use-serve-static-preview-for-command.ts b/packages/playwright/src/migrations/update-19-6-0/use-serve-static-preview-for-command.ts index c98bfff41a031..88b58a6e6ddfc 100644 --- a/packages/playwright/src/migrations/update-19-6-0/use-serve-static-preview-for-command.ts +++ b/packages/playwright/src/migrations/update-19-6-0/use-serve-static-preview-for-command.ts @@ -8,6 +8,7 @@ import { visitNotIgnoredFiles, } from '@nx/devkit'; import { tsquery } from '@phenomnomnominal/tsquery'; +import addE2eCiTargetDefaults from './add-e2e-ci-target-defaults'; export default async function (tree: Tree) { const graph = await createProjectGraphAsync(); @@ -138,5 +139,6 @@ export default async function (tree: Tree) { } }); + await addE2eCiTargetDefaults(tree); await formatFiles(tree); } diff --git a/packages/react/src/generators/application/application.spec.ts b/packages/react/src/generators/application/application.spec.ts index e223343c079f6..d1d84e6a767bb 100644 --- a/packages/react/src/generators/application/application.spec.ts +++ b/packages/react/src/generators/application/application.spec.ts @@ -1086,4 +1086,99 @@ describe('app', () => { } `); }); + + it('should add e2e-ci targetDefaults to nxJson when addPlugin=true with playwright', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + let nxJson = readNxJson(tree); + delete nxJson.targetDefaults; + updateNxJson(tree, nxJson); + + // ACT + await applicationGenerator(tree, { + name: 'myapp', + addPlugin: true, + linter: Linter.None, + style: 'none', + e2eTestRunner: 'playwright', + }); + + // ASSERT + nxJson = readNxJson(tree); + expect(nxJson.targetDefaults).toMatchInlineSnapshot(` + { + "e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + } + `); + }); + + it('should add e2e-ci targetDefaults to nxJson when addPlugin=true with cypress', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + let nxJson = readNxJson(tree); + delete nxJson.targetDefaults; + updateNxJson(tree, nxJson); + + // ACT + await applicationGenerator(tree, { + name: 'myapp', + addPlugin: true, + linter: Linter.None, + style: 'none', + e2eTestRunner: 'cypress', + }); + + // ASSERT + nxJson = readNxJson(tree); + expect(nxJson.targetDefaults).toMatchInlineSnapshot(` + { + "e2e-ci--**/*": { + "dependsOn": [ + "^build", + ], + }, + } + `); + }); + + it('should add e2e-ci targetDefaults to nxJson when addPlugin=true with cypress and use the defined webpack buildTargetName', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + let nxJson = readNxJson(tree); + delete nxJson.targetDefaults; + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/webpack/plugin', + options: { + buildTargetName: 'build-base', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await applicationGenerator(tree, { + name: 'myapp', + addPlugin: true, + linter: Linter.None, + style: 'none', + bundler: 'webpack', + e2eTestRunner: 'cypress', + }); + + // ASSERT + nxJson = readNxJson(tree); + expect(nxJson.targetDefaults).toMatchInlineSnapshot(` + { + "e2e-ci--**/*": { + "dependsOn": [ + "^build-base", + ], + }, + } + `); + }); }); diff --git a/packages/react/src/generators/application/lib/add-e2e.ts b/packages/react/src/generators/application/lib/add-e2e.ts index 5da77e12ce49a..2f09da931de21 100644 --- a/packages/react/src/generators/application/lib/add-e2e.ts +++ b/packages/react/src/generators/application/lib/add-e2e.ts @@ -4,6 +4,7 @@ import { ensurePackage, getPackageManagerCommand, joinPathFragments, + readNxJson, } from '@nx/devkit'; import { webStaticServeGenerator } from '@nx/web'; @@ -11,6 +12,8 @@ import { nxVersion } from '../../../utils/versions'; import { hasWebpackPlugin } from '../../../utils/has-webpack-plugin'; import { hasVitePlugin } from '../../../utils/has-vite-plugin'; import { NormalizedSchema } from '../schema'; +import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; +import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function addE2e( tree: Tree, @@ -41,7 +44,7 @@ export async function addE2e( tags: [], }); - return await configurationGenerator(tree, { + const e2eTask = await configurationGenerator(tree, { ...options, project: options.e2eProjectName, directory: 'src', @@ -64,6 +67,46 @@ export async function addE2e( ciBaseUrl: options.bundler === 'vite' ? options.e2eCiBaseUrl : undefined, }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasNxBuildPlugin) { + const configFile = + options.bundler === 'webpack' + ? 'webpack.config.js' + : options.bundler === 'vite' + ? `vite.config.${options.js ? 'js' : 'ts'}` + : 'webpack.config.js'; + const matchingPlugin = await findPluginForConfigFile( + tree, + `@nx/${options.bundler}/plugin`, + joinPathFragments(options.appProjectRoot, configFile) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + buildTarget, + joinPathFragments( + options.e2eProjectRoot, + `cypress.config.${options.js ? 'js' : 'ts'}` + ) + ); + } + + return e2eTask; } case 'playwright': { const { configurationGenerator } = ensurePackage< @@ -76,7 +119,7 @@ export async function addE2e( targets: {}, implicitDependencies: [options.projectName], }); - return configurationGenerator(tree, { + const e2eTask = await configurationGenerator(tree, { project: options.e2eProjectName, skipFormat: true, skipPackageJson: options.skipPackageJson, @@ -91,6 +134,43 @@ export async function addE2e( rootProject: options.rootProject, addPlugin: options.addPlugin, }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasNxBuildPlugin) { + const configFile = + options.bundler === 'webpack' + ? 'webpack.config.js' + : options.bundler === 'vite' + ? `vite.config.${options.js ? 'js' : 'ts'}` + : 'webpack.config.js'; + const matchingPlugin = await findPluginForConfigFile( + tree, + `@nx/${options.bundler}/plugin`, + joinPathFragments(options.appProjectRoot, configFile) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/playwright/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `playwright.config.ts`) + ); + } + + return e2eTask; } case 'none': default: diff --git a/packages/remix/src/generators/application/application.impl.spec.ts b/packages/remix/src/generators/application/application.impl.spec.ts index 75d8ed5cad92d..f9b0d2576758a 100644 --- a/packages/remix/src/generators/application/application.impl.spec.ts +++ b/packages/remix/src/generators/application/application.impl.spec.ts @@ -1,6 +1,6 @@ import 'nx/src/internal-testing-utils/mock-project-graph'; -import { joinPathFragments, readJson, type Tree } from '@nx/devkit'; +import { joinPathFragments, readJson, readNxJson, type Tree } from '@nx/devkit'; import * as devkit from '@nx/devkit'; import { ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; @@ -129,6 +129,14 @@ describe('Remix Application', () => { expectTargetsToBeCorrect(tree, '.'); expect(tree.read('e2e/cypress.config.ts', 'utf-8')).toMatchSnapshot(); + expect(readNxJson(tree).targetDefaults['e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); }); }); @@ -148,6 +156,14 @@ describe('Remix Application', () => { expectTargetsToBeCorrect(tree, '.'); expect(tree.read('e2e/playwright.config.ts', 'utf-8')).toMatchSnapshot(); + expect(readNxJson(tree).targetDefaults['e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); }); }); diff --git a/packages/remix/src/generators/application/application.impl.ts b/packages/remix/src/generators/application/application.impl.ts index fc99f0f8653b8..e86017360f294 100644 --- a/packages/remix/src/generators/application/application.impl.ts +++ b/packages/remix/src/generators/application/application.impl.ts @@ -33,7 +33,7 @@ import { NxRemixGeneratorSchema } from './schema'; import { updateDependencies } from '../utils/update-dependencies'; import initGenerator from '../init/init'; import { initGenerator as jsInitGenerator } from '@nx/js'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command'; import { updateJestTestMatch } from '../../utils/testing-config-utils'; diff --git a/packages/remix/src/generators/application/lib/add-e2e.ts b/packages/remix/src/generators/application/lib/add-e2e.ts index 6f5fec99e607c..43283705b7b02 100644 --- a/packages/remix/src/generators/application/lib/add-e2e.ts +++ b/packages/remix/src/generators/application/lib/add-e2e.ts @@ -6,11 +6,19 @@ import { updateProjectConfiguration, ensurePackage, getPackageManagerCommand, + readNxJson, } from '@nx/devkit'; import { type NormalizedSchema } from './normalize-options'; import { getPackageVersion } from '../../../utils/versions'; +import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; +import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function addE2E(tree: Tree, options: NormalizedSchema) { + const hasRemixPlugin = readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/remix/plugin' + : p.plugin === '@nx/remix/plugin' + ); if (options.e2eTestRunner === 'cypress') { const { configurationGenerator } = ensurePackage< typeof import('@nx/cypress') @@ -25,7 +33,7 @@ export async function addE2E(tree: Tree, options: NormalizedSchema) { implicitDependencies: [options.projectName], }); - return await configurationGenerator(tree, { + const e2eTask = await configurationGenerator(tree, { project: options.e2eProjectName, directory: 'src', skipFormat: true, @@ -33,6 +41,40 @@ export async function addE2E(tree: Tree, options: NormalizedSchema) { baseUrl: options.e2eWebServerAddress, addPlugin: options.addPlugin, }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasRemixPlugin) { + const matchingPlugin = await findPluginForConfigFile( + tree, + `@nx/remix/plugin`, + joinPathFragments(options.projectRoot, 'remix.config.js') + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + buildTarget, + joinPathFragments( + options.e2eProjectRoot, + `cypress.config.${options.js ? 'js' : 'ts'}` + ) + ); + } + + return e2eTask; } else if (options.e2eTestRunner === 'playwright') { const { configurationGenerator } = ensurePackage< typeof import('@nx/playwright') @@ -47,7 +89,7 @@ export async function addE2E(tree: Tree, options: NormalizedSchema) { implicitDependencies: [options.projectName], }); - return configurationGenerator(tree, { + const e2eTask = await configurationGenerator(tree, { project: options.e2eProjectName, skipFormat: true, skipPackageJson: false, @@ -62,6 +104,37 @@ export async function addE2E(tree: Tree, options: NormalizedSchema) { rootProject: options.rootProject, addPlugin: options.addPlugin, }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasRemixPlugin) { + const matchingPlugin = await findPluginForConfigFile( + tree, + `@nx/remix/plugin`, + joinPathFragments(options.projectRoot, 'remix.config.js') + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/playwright/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `playwright.config.ts`) + ); + } + + return e2eTask; } else { return () => {}; } diff --git a/packages/rollup/src/generators/configuration/configuration.ts b/packages/rollup/src/generators/configuration/configuration.ts index e28a07b3be0e0..f12e0c584b43d 100644 --- a/packages/rollup/src/generators/configuration/configuration.ts +++ b/packages/rollup/src/generators/configuration/configuration.ts @@ -16,7 +16,7 @@ import { getImportPath } from '@nx/js/src/utils/get-import-path'; import { rollupInitGenerator } from '../init/init'; import { RollupExecutorOptions } from '../../executors/rollup/schema'; import { RollupProjectSchema } from './schema'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; import { ensureDependencies } from '../../utils/ensure-dependencies'; import { hasPlugin } from '../../utils/has-plugin'; import { RollupWithNxPluginOptions } from '../../plugins/with-nx/with-nx-options'; diff --git a/packages/vite/src/utils/generator-utils.ts b/packages/vite/src/utils/generator-utils.ts index 0b7c2790f155c..a1a997c02a493 100644 --- a/packages/vite/src/utils/generator-utils.ts +++ b/packages/vite/src/utils/generator-utils.ts @@ -14,7 +14,7 @@ import { VitePreviewServerExecutorOptions } from '../executors/preview-server/sc import { VitestExecutorOptions } from '../executors/test/schema'; import { ViteConfigurationGeneratorSchema } from '../generators/configuration/schema'; import { ensureViteConfigIsCorrect } from './vite-config-edit-utils'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export type Target = 'build' | 'serve' | 'test' | 'preview'; export type TargetFlags = Partial>; diff --git a/packages/vue/src/generators/application/application.spec.ts b/packages/vue/src/generators/application/application.spec.ts index aab1a17a2300c..d52eb53c323ed 100644 --- a/packages/vue/src/generators/application/application.spec.ts +++ b/packages/vue/src/generators/application/application.spec.ts @@ -40,6 +40,7 @@ describe('application generator', () => { ...options, unitTestRunner: 'vitest', e2eTestRunner: 'playwright', + addPlugin: true, }); expect(tree.read('.eslintrc.json', 'utf-8')).toMatchSnapshot(); expect(tree.read('test/vite.config.ts', 'utf-8')).toMatchSnapshot(); @@ -49,6 +50,14 @@ describe('application generator', () => { tree.read('test-e2e/playwright.config.ts', 'utf-8') ).toMatchSnapshot(); expect(listFiles(tree)).toMatchSnapshot(); + expect(readNxJson(tree).targetDefaults['e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); }); it('should set up project correctly for cypress', async () => { diff --git a/packages/vue/src/generators/application/lib/add-e2e.ts b/packages/vue/src/generators/application/lib/add-e2e.ts index 9268da0e2bd16..ae2578e334e9c 100644 --- a/packages/vue/src/generators/application/lib/add-e2e.ts +++ b/packages/vue/src/generators/application/lib/add-e2e.ts @@ -10,6 +10,8 @@ import { webStaticServeGenerator } from '@nx/web'; import { nxVersion } from '../../../utils/versions'; import { NormalizedSchema } from '../schema'; +import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; +import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; export async function addE2e( tree: Tree, @@ -52,7 +54,7 @@ export async function addE2e( tags: [], implicitDependencies: [options.projectName], }); - return await configurationGenerator(tree, { + const e2eTask = await configurationGenerator(tree, { ...options, project: options.e2eProjectName, directory: 'src', @@ -70,6 +72,43 @@ export async function addE2e( ciWebServerCommand: `nx run ${options.projectName}:${e2eCiWebServerTarget}`, ciBaseUrl: 'http://localhost:4300', }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasPlugin) { + const matchingPlugin = await findPluginForConfigFile( + tree, + `@nx/vite/plugin`, + joinPathFragments( + options.appProjectRoot, + `vite.config.${options.js ? 'js' : 'ts'}` + ) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/cypress/plugin', + buildTarget, + joinPathFragments( + options.e2eProjectRoot, + `cypress.config.${options.js ? 'js' : 'ts'}` + ) + ); + } + + return e2eTask; } case 'playwright': { const { configurationGenerator } = ensurePackage< @@ -82,7 +121,7 @@ export async function addE2e( targets: {}, implicitDependencies: [options.projectName], }); - return configurationGenerator(tree, { + const e2eTask = await configurationGenerator(tree, { ...options, project: options.e2eProjectName, skipFormat: true, @@ -96,6 +135,40 @@ export async function addE2e( }:${e2eCiWebServerTarget}`, webServerAddress: 'http://localhost:4300', }); + + if ( + options.addPlugin || + readNxJson(tree).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasPlugin) { + const matchingPlugin = await findPluginForConfigFile( + tree, + `@nx/vite/plugin`, + joinPathFragments( + options.appProjectRoot, + `vite.config.${options.js ? 'js' : 'ts'}` + ) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + tree, + '@nx/playwright/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `playwright.config.ts`) + ); + } + + return e2eTask; } case 'none': default: diff --git a/packages/web/src/generators/application/application.spec.ts b/packages/web/src/generators/application/application.spec.ts index 6aec78f324b93..cab0c67e7d4c2 100644 --- a/packages/web/src/generators/application/application.spec.ts +++ b/packages/web/src/generators/application/application.spec.ts @@ -43,6 +43,14 @@ describe('app', () => { expect(readProjectConfiguration(tree, 'my-app-e2e').root).toEqual( 'my-app-e2e' ); + expect(readNxJson(tree).targetDefaults['e2e-ci--**/*']) + .toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + ], + } + `); }, 60_000); it('should update tags and implicit dependencies', async () => { diff --git a/packages/web/src/generators/application/application.ts b/packages/web/src/generators/application/application.ts index 5b0df944b12fc..9dbed0511df91 100644 --- a/packages/web/src/generators/application/application.ts +++ b/packages/web/src/generators/application/application.ts @@ -9,6 +9,7 @@ import { joinPathFragments, names, offsetFromRoot, + type PluginConfiguration, readNxJson, readProjectConfiguration, runTasksInSerial, @@ -37,12 +38,15 @@ import { webInitGenerator } from '../init/init'; import { Schema } from './schema'; import { getNpmScope } from '@nx/js/src/utils/package-json/get-npm-scope'; import { hasWebpackPlugin } from '../../utils/has-webpack-plugin'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { + addBuildTargetDefaults, + addE2eCiTargetDefaults, +} from '@nx/devkit/src/generators/target-defaults-utils'; import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command'; import { VitePluginOptions } from '@nx/vite/src/plugins/plugin'; import { WebpackPluginOptions } from '@nx/webpack/src/plugins/plugin'; -import { hasVitePlugin } from '../../utils/has-vite-plugin'; import staticServeConfiguration from '../static-serve/static-serve-configuration'; +import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; interface NormalizedSchema extends Schema { projectName: string; @@ -368,10 +372,20 @@ export async function applicationGeneratorInternal(host: Tree, schema: Schema) { tasks.push(lintTask); } - const hasNxBuildPlugin = - (options.bundler === 'webpack' && hasWebpackPlugin(host)) || - (options.bundler === 'vite' && hasVitePlugin(host)); - if (!hasNxBuildPlugin) { + const nxJson = readNxJson(host); + let hasPlugin: PluginConfiguration | undefined; + let buildPlugin: string; + let buildConfigFile: string; + if (options.bundler === 'webpack' || options.bundler === 'vite') { + buildPlugin = `@nx/${options.bundler}/plugin`; + buildConfigFile = + options.bundler === 'webpack' ? 'webpack.config.js' : `vite.config.ts`; + hasPlugin = nxJson.plugins?.find((p) => + typeof p === 'string' ? p === buildPlugin : p.plugin === buildPlugin + ); + } + + if (!hasPlugin) { await staticServeConfiguration(host, { buildTarget: `${options.projectName}:build`, spa: true, @@ -396,17 +410,47 @@ export async function applicationGeneratorInternal(host: Tree, schema: Schema) { baseUrl: options.e2eWebServerAddress, directory: 'src', skipFormat: true, - webServerCommands: hasNxBuildPlugin + webServerCommands: hasPlugin ? { default: `nx run ${options.projectName}:${options.e2eWebServerTarget}`, production: `nx run ${options.projectName}:preview`, } : undefined, - ciWebServerCommand: hasNxBuildPlugin + ciWebServerCommand: hasPlugin ? `nx run ${options.projectName}:${options.e2eCiWebServerTarget}` : undefined, ciBaseUrl: options.bundler === 'vite' ? options.e2eCiBaseUrl : undefined, }); + + if ( + options.addPlugin || + readNxJson(host).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasPlugin) { + const matchingPlugin = await findPluginForConfigFile( + host, + buildPlugin, + joinPathFragments(options.appProjectRoot, buildConfigFile) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + host, + '@nx/cypress/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `cypress.config.ts`) + ); + } + tasks.push(cypressTask); } else if (options.e2eTestRunner === 'playwright') { const { configurationGenerator: playwrightConfigGenerator } = ensurePackage< @@ -434,6 +478,36 @@ export async function applicationGeneratorInternal(host: Tree, schema: Schema) { webServerAddress: options.e2eCiBaseUrl, addPlugin: options.addPlugin, }); + + if ( + options.addPlugin || + readNxJson(host).plugins?.find((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ) + ) { + let buildTarget = '^build'; + if (hasPlugin) { + const matchingPlugin = await findPluginForConfigFile( + host, + buildPlugin, + joinPathFragments(options.appProjectRoot, buildConfigFile) + ); + if (matchingPlugin && typeof matchingPlugin !== 'string') { + buildTarget = `^${ + (matchingPlugin.options as any)?.buildTargetName ?? 'build' + }`; + } + } + await addE2eCiTargetDefaults( + host, + '@nx/playwright/plugin', + buildTarget, + joinPathFragments(options.e2eProjectRoot, `playwright.config.ts`) + ); + } + tasks.push(playwrightTask); } if (options.unitTestRunner === 'jest') { diff --git a/packages/webpack/src/generators/configuration/configuration.ts b/packages/webpack/src/generators/configuration/configuration.ts index d655004455241..f4cd67321e320 100644 --- a/packages/webpack/src/generators/configuration/configuration.ts +++ b/packages/webpack/src/generators/configuration/configuration.ts @@ -15,7 +15,7 @@ import { webpackInitGenerator } from '../init/init'; import { ConfigurationGeneratorSchema } from './schema'; import { WebpackExecutorOptions } from '../../executors/webpack/schema'; import { hasPlugin } from '../../utils/has-plugin'; -import { addBuildTargetDefaults } from '@nx/devkit/src/generators/add-build-target-defaults'; +import { addBuildTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils'; import { ensureDependencies } from '../../utils/ensure-dependencies'; export function configurationGenerator( diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f74457191501..e93b67d7681e8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -115,6 +115,7 @@ "@nx/nx-dev/ui-theme": ["nx-dev/ui-theme/src/index.ts"], "@nx/nx-dev/util-ai": ["nx-dev/util-ai/src/index.ts"], "@nx/playwright": ["packages/playwright/index.ts"], + "@nx/playwright/*": ["packages/playwright/*"], "@nx/plugin": ["packages/plugin"], "@nx/plugin/*": ["packages/plugin/*"], "@nx/react": ["packages/react"], From 0f193e21ce113945f3ad008d5352569a7829789e Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Wed, 7 Aug 2024 12:27:16 -0400 Subject: [PATCH 5/6] fix(core): allow configuring plugin message timeout (#27315) ## Current Behavior Plugin messages timeout if they don't hear back in 5 minutes. This adds timeouts to createNodes and other plugin APIs invoked during graph construction, which didn't previously exist ## Expected Behavior Increased timeout + easy configuration via env var ## Related Issue(s) Fixes # --- .../shared/reference/environment-variables.md | 1 + .../plugins/isolation/plugin-pool.ts | 124 +++++++++++++----- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/docs/shared/reference/environment-variables.md b/docs/shared/reference/environment-variables.md index 5971752b0ff16..ef0e4b96737fc 100644 --- a/docs/shared/reference/environment-variables.md +++ b/docs/shared/reference/environment-variables.md @@ -31,6 +31,7 @@ The following environment variables are ones that you can set to change the beha | NX_MIGRATE_CLI_VERSION | string | The version of Nx to use for running the `nx migrate` command. If not set, it defaults to `latest`. | | NX_LOAD_DOT_ENV_FILES | boolean | If set to 'false', Nx will not load any environment files (e.g. `.local.env`, `.env.local`) | | NX_NATIVE_FILE_CACHE_DIRECTORY | string | The cache for native `.node` files is stored under a global temp directory by default. Set this variable to use a different directory. This is interpreted as an absolute path. | +| NX_PLUGIN_NO_TIMEOUTS | boolean | If set to `true`, plugin operations will not timeout | Nx will set the following environment variables so they can be accessible within the process even outside of executors and generators. 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 b7a87781b1a2e..c0df2ddebfcde 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,15 @@ const cleanupFunctions = new Set<() => void>(); const pluginNames = new Map(); -const MAX_MESSAGE_WAIT = 1000 * 60 * 5; // 5 minutes +const PLUGIN_TIMEOUT_HINT_TEXT = + 'As a last resort, you can set NX_PLUGIN_NO_TIMEOUTS=true to bypass this timeout.'; + +const MINUTES = 10; + +const MAX_MESSAGE_WAIT = + process.env.NX_PLUGIN_NO_TIMEOUTS === 'true' + ? undefined + : 1000 * 60 * MINUTES; // 10 minutes interface PendingPromise { promise: Promise; @@ -67,9 +75,15 @@ export async function loadRemoteNxPlugin( }); // logger.verbose(`[plugin-worker] started worker: ${worker.pid}`); - const loadTimeout = setTimeout(() => { - rej(new Error('Plugin worker timed out when loading plugin:' + plugin)); - }, MAX_MESSAGE_WAIT); + const loadTimeout = MAX_MESSAGE_WAIT + ? setTimeout(() => { + rej( + new Error( + `Loading "${plugin}" timed out after ${MINUTES} minutes. ${PLUGIN_TIMEOUT_HINT_TEXT}` + ) + ); + }, MAX_MESSAGE_WAIT) + : undefined; socket.on( 'data', @@ -78,7 +92,7 @@ export async function loadRemoteNxPlugin( worker, pendingPromises, (val) => { - clearTimeout(loadTimeout); + if (loadTimeout) clearTimeout(loadTimeout); res(val); }, rej, @@ -144,12 +158,20 @@ function createWorkerHandler( (configFiles, ctx) => { const tx = pluginName + worker.pid + ':createNodes:' + txId++; - return registerPendingPromise(tx, pending, () => { - sendMessageOverSocket(socket, { - type: 'createNodes', - payload: { configFiles, context: ctx, tx }, - }); - }); + return registerPendingPromise( + tx, + pending, + () => { + sendMessageOverSocket(socket, { + type: 'createNodes', + payload: { configFiles, context: ctx, tx }, + }); + }, + { + plugin: pluginName, + operation: 'createNodes', + } + ); }, ] : undefined, @@ -157,36 +179,60 @@ function createWorkerHandler( ? (ctx) => { const tx = pluginName + worker.pid + ':createDependencies:' + txId++; - return registerPendingPromise(tx, pending, () => { - sendMessageOverSocket(socket, { - type: 'createDependencies', - payload: { context: ctx, tx }, - }); - }); + return registerPendingPromise( + tx, + pending, + () => { + sendMessageOverSocket(socket, { + type: 'createDependencies', + payload: { context: ctx, tx }, + }); + }, + { + plugin: pluginName, + operation: 'createDependencies', + } + ); } : undefined, processProjectGraph: result.hasProcessProjectGraph ? (graph, ctx) => { const tx = pluginName + worker.pid + ':processProjectGraph:' + txId++; - return registerPendingPromise(tx, pending, () => { - sendMessageOverSocket(socket, { - type: 'processProjectGraph', - payload: { graph, ctx, tx }, - }); - }); + return registerPendingPromise( + tx, + pending, + () => { + sendMessageOverSocket(socket, { + type: 'processProjectGraph', + payload: { graph, ctx, tx }, + }); + }, + { + operation: 'processProjectGraph', + plugin: pluginName, + } + ); } : undefined, createMetadata: result.hasCreateMetadata ? (graph, ctx) => { const tx = pluginName + worker.pid + ':createMetadata:' + txId++; - return registerPendingPromise(tx, pending, () => { - sendMessageOverSocket(socket, { - type: 'createMetadata', - payload: { graph, context: ctx, tx }, - }); - }); + return registerPendingPromise( + tx, + pending, + () => { + sendMessageOverSocket(socket, { + type: 'createMetadata', + payload: { graph, context: ctx, tx }, + }); + }, + { + plugin: pluginName, + operation: 'createMetadata', + } + ); } : undefined, }); @@ -265,7 +311,11 @@ process.on('SIGTERM', exitHandler); function registerPendingPromise( tx: string, pending: Map, - callback: () => void + callback: () => void, + context: { + plugin: string; + operation: string; + } ): Promise { let resolver, rejector, timeout; @@ -273,14 +323,20 @@ function registerPendingPromise( rejector = rej; resolver = res; - timeout = setTimeout(() => { - rej(new Error(`Plugin worker timed out when processing message ${tx}`)); - }, MAX_MESSAGE_WAIT); + timeout = MAX_MESSAGE_WAIT + ? setTimeout(() => { + rej( + new Error( + `${context.plugin} timed out after ${MINUTES} minutes during ${context.operation}. ${PLUGIN_TIMEOUT_HINT_TEXT}` + ) + ); + }, MAX_MESSAGE_WAIT) + : undefined; callback(); }).finally(() => { pending.delete(tx); - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); }); pending.set(tx, { From 40d351602070f763d91b034b17057fc1a423e04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 7 Aug 2024 19:29:27 +0200 Subject: [PATCH 6/6] fix(js): locate npm nodes correctly for aliased packages (#27124) --- .../target-project-locator.spec.ts | 38 +++++++++++++++++++ .../target-project-locator.ts | 9 ++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts index 16199daf5b8d2..fc15bf32b8490 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts @@ -96,6 +96,14 @@ describe('TargetProjectLocator', () => { name: '@proj/proj123-base', version: '1.0.0', }), + './node_modules/lodash/package.json': JSON.stringify({ + name: 'lodash', + version: '3.0.0', + }), + './node_modules/lodash-4/package.json': JSON.stringify({ + name: 'lodash', + version: '4.0.0', + }), }; vol.fromJSON(fsJson, '/root'); projects = { @@ -263,6 +271,22 @@ describe('TargetProjectLocator', () => { packageName: '@proj/proj123-base', }, }, + 'npm:lodash': { + name: 'npm:lodash', + type: 'npm', + data: { + version: '3.0.0', + packageName: 'lodash', + }, + }, + 'npm:lodash-4': { + name: 'npm:lodash-4', + type: 'npm', + data: { + packageName: 'lodash-4', + version: 'npm:lodash@4.0.0', + }, + }, }; targetProjectLocator = new TargetProjectLocator(projects, npmProjects); @@ -454,6 +478,20 @@ describe('TargetProjectLocator', () => { ); expect(proj5).toEqual('proj5'); }); + + it('should be able to resolve packages alises', () => { + const lodash = targetProjectLocator.findProjectFromImport( + 'lodash', + 'libs/proj/index.ts' + ); + expect(lodash).toEqual('npm:lodash'); + + const lodash4 = targetProjectLocator.findProjectFromImport( + 'lodash-4', + 'libs/proj/index.ts' + ); + expect(lodash4).toEqual('npm:lodash-4'); + }); }); describe('findTargetProjectWithImport (without tsconfig.json)', () => { diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts index 5383b685af690..86b21bd19875b 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts @@ -188,9 +188,14 @@ export class TargetProjectLocator { const version = clean(externalPackageJson.version); const npmProjectKey = `npm:${externalPackageJson.name}@${version}`; - const matchingExternalNode = this.npmProjects[npmProjectKey]; + let matchingExternalNode = this.npmProjects[npmProjectKey]; if (!matchingExternalNode) { - return null; + // check if it's a package alias, where the resolved package key is used as the version + const aliasNpmProjectKey = `npm:${packageName}@${npmProjectKey}`; + matchingExternalNode = this.npmProjects[aliasNpmProjectKey]; + if (!matchingExternalNode) { + return null; + } } this.npmResolutionCache.set(