diff --git a/e2e/nx-run/src/run.test.ts b/e2e/nx-run/src/run.test.ts index 792d482c26f687..fed424a9625901 100644 --- a/e2e/nx-run/src/run.test.ts +++ b/e2e/nx-run/src/run.test.ts @@ -115,6 +115,73 @@ describe('Nx Running Tests', () => { updateProjectConfig(mylib, (c) => original); }, 1000000); + + describe('tokens support', () => { + let app: string; + + beforeAll(() => { + app = uniq('myapp'); + runCLI(`generate @nx/web:app ${app}`); + }); + + it('should support using {projectRoot} in options blocks in project.json', async () => { + updateProjectConfig(app, (c) => { + c.targets['echo'] = { + command: `node -e 'console.log("{projectRoot}")'`, + }; + return c; + }); + + const output = runCLI(`echo ${app}`); + expect(output).toContain(`apps/${app}`); + }); + + it('should support using {projectName} in options blocks in project.json', async () => { + updateProjectConfig(app, (c) => { + c.targets['echo'] = { + command: `node -e 'console.log("{projectName}")'`, + }; + return c; + }); + + const output = runCLI(`echo ${app}`); + expect(output).toContain(app); + }); + + it('should support using {projectRoot} in targetDefaults', async () => { + updateJson(`nx.json`, (json) => { + json.targetDefaults = { + echo: { + command: `node -e 'console.log("{projectRoot}")'`, + }, + }; + return json; + }); + updateProjectConfig(app, (c) => { + c.targets['echo'] = {}; + return c; + }); + const output = runCLI(`echo ${app}`); + expect(output).toContain(`apps/${app}`); + }); + + it('should support using {projectName} in targetDefaults', async () => { + updateJson(`nx.json`, (json) => { + json.targetDefaults = { + echo: { + command: `node -e 'console.log("{projectName}")'`, + }, + }; + return json; + }); + updateProjectConfig(app, (c) => { + c.targets['echo'] = {}; + return c; + }); + const output = runCLI(`echo ${app}`); + expect(output).toContain(app); + }); + }); }); describe('Nx Bail', () => { diff --git a/packages/nx/src/config/workspaces.spec.ts b/packages/nx/src/config/workspaces.spec.ts index ed9e39558cfde6..e80b063bc6e082 100644 --- a/packages/nx/src/config/workspaces.spec.ts +++ b/packages/nx/src/config/workspaces.spec.ts @@ -256,30 +256,6 @@ describe('Workspaces', () => { ).options ).toEqual({ a: 'project-value' }); }); - - it('should resolve workspaceRoot and projectRoot tokens', () => { - expect( - mergeTargetConfigurations( - { - root: 'my/project', - targets: { - build: { - options: { - a: '{workspaceRoot}', - }, - }, - }, - }, - 'build', - { - executor: 'target', - options: { - b: '{workspaceRoot}/dist/{projectRoot}', - }, - } - ).options - ).toEqual({ a: '{workspaceRoot}', b: 'dist/my/project' }); - }); }); describe('configurations', () => { @@ -392,37 +368,6 @@ describe('Workspaces', () => { ).configurations ).toEqual(projectConfigurations); }); - - it('should resolve workspaceRoot and projectRoot tokens', () => { - expect( - mergeTargetConfigurations( - { - root: 'my/project', - targets: { - build: { - configurations: { - dev: { - a: '{workspaceRoot}', - }, - }, - }, - }, - }, - 'build', - { - executor: 'target', - configurations: { - prod: { - a: '{workspaceRoot}/dist/{projectRoot}', - }, - }, - } - ).configurations - ).toEqual({ - dev: { a: '{workspaceRoot}' }, - prod: { a: 'dist/my/project' }, - }); - }); }); describe('defaultConfiguration', () => { diff --git a/packages/nx/src/config/workspaces.ts b/packages/nx/src/config/workspaces.ts index 8febcdb540a542..3d960867dad2a5 100644 --- a/packages/nx/src/config/workspaces.ts +++ b/packages/nx/src/config/workspaces.ts @@ -88,7 +88,7 @@ export class Workspaces { ) { for (const proj of Object.values(projects)) { if (proj.targets) { - for (const targetName of Object.keys(proj.targets)) { + for (const targetName of Object.keys(proj.targets ?? {})) { const projectTargetDefinition = proj.targets[targetName]; const defaults = readTargetDefaultsForTarget( targetName, @@ -393,53 +393,34 @@ export function mergeTargetConfigurations( !targetConfiguration.executor || targetDefaults.executor === targetConfiguration.executor ) { - result.options = mergeOptions( - defaultOptions, - targetConfiguration.options ?? {}, - projectConfiguration, - target - ); + result.options = { ...defaultOptions, ...targetConfiguration?.options }; result.configurations = mergeConfigurations( defaultConfigurations, - targetConfiguration.configurations, - projectConfiguration, - target + targetConfiguration.configurations ); } return result as TargetConfiguration; } -function mergeOptions( - defaults: T, - options: T, - project: ProjectConfiguration, - key: string -): T { - return { - ...resolvePathTokensInOptions(defaults, project, key), - ...options, - }; -} - function mergeConfigurations( defaultConfigurations: Record, - projectDefinedConfigurations: Record, - project: ProjectConfiguration, - targetName: string + projectDefinedConfigurations: Record ): Record { - const configurations: Record = { ...projectDefinedConfigurations }; - for (const configuration in defaultConfigurations) { - configurations[configuration] = mergeOptions( - defaultConfigurations[configuration], - configurations[configuration], - project, - `${targetName}.${configuration}` - ); + const result: Record = {}; + const configurations = new Set([ + ...Object.keys(defaultConfigurations ?? {}), + ...Object.keys(projectDefinedConfigurations ?? {}), + ]); + for (const configuration of configurations) { + result[configuration] = { + ...(defaultConfigurations?.[configuration] ?? ({} as T)), + ...(projectDefinedConfigurations?.[configuration] ?? ({} as T)), + }; } - return configurations; + return result; } -function resolvePathTokensInOptions>( +export function resolveNxTokensInOptions>( object: T, project: ProjectConfiguration, key: string @@ -447,10 +428,10 @@ function resolvePathTokensInOptions>( const result: T = Array.isArray(object) ? ([...object] as T) : { ...object }; for (let [opt, value] of Object.entries(object ?? {})) { if (typeof value === 'string') { - if (value.startsWith('{workspaceRoot}/')) { - value = value.replace(/^\{workspaceRoot\}\//, ''); - } - if (value.includes('{workspaceRoot}')) { + const workspaceRootMatch = /^(\{workspaceRoot\}\/?)/.exec(value); + if (workspaceRootMatch?.length) { + value = value.replace(workspaceRootMatch[0], ''); + } else if (value.includes('{workspaceRoot}')) { throw new Error( `${NX_PREFIX} The {workspaceRoot} token is only valid at the beginning of an option. (${key})` ); @@ -458,7 +439,7 @@ function resolvePathTokensInOptions>( value = value.replace(/\{projectRoot\}/g, project.root); result[opt] = value.replace(/\{projectName\}/g, project.name); } else if (typeof value === 'object' && value) { - result[opt] = resolvePathTokensInOptions( + result[opt] = resolveNxTokensInOptions( value, project, [key, opt].join('.') diff --git a/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts b/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts index 56858c0f718e15..9d57611319d137 100644 --- a/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts +++ b/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts @@ -1,5 +1,8 @@ import { ProjectGraphProjectNode } from '../../config/project-graph'; -import { normalizeImplicitDependencies } from './workspace-projects'; +import { + normalizeImplicitDependencies, + normalizeProjectTargets, +} from './workspace-projects'; describe('workspace-projects', () => { let projectGraph: Record = { @@ -75,4 +78,228 @@ describe('workspace-projects', () => { ).toEqual(['b', 'b-1', 'b-2']); }); }); + + describe('normalizeTargets', () => { + it('should apply target defaults', () => { + expect( + normalizeProjectTargets( + { + root: 'my/project', + targets: { + build: { + executor: 'target', + options: { + a: 'a', + }, + }, + }, + }, + { + build: { + executor: 'target', + options: { + b: 'b', + }, + }, + }, + 'build' + ).build.options + ).toEqual({ a: 'a', b: 'b' }); + }); + + it('should overwrite target defaults when type doesnt match or provided an array', () => { + expect( + normalizeProjectTargets( + { + root: 'my/project', + targets: { + build: { + executor: 'target', + options: { + a: 'a', + b: ['project-value'], + c: 'project-value', + }, + }, + }, + }, + { + build: { + executor: 'target', + options: { + a: 1, + b: ['default-value'], + c: ['default-value'], + }, + }, + }, + 'build' + ).build.options + ).toEqual({ a: 'a', b: ['project-value'], c: 'project-value' }); + }); + + it('should overwrite object options from target defaults', () => { + expect( + normalizeProjectTargets( + { + root: 'my/project', + targets: { + build: { + executor: 'target', + options: { + a: 'a', + b: { + a: 'a', + b: 'project-value', + }, + }, + }, + }, + }, + { + build: { + executor: 'target', + options: { + b: { + b: 'default-value', + c: 'c', + }, + }, + }, + }, + 'build' + ).build.options + ).toEqual({ + a: 'a', + b: { + a: 'a', + b: 'project-value', + }, + }); + }); + + it('should convert command property to run-commands executor', () => { + expect( + normalizeProjectTargets( + { + root: 'my/project', + targets: { + build: { + command: 'echo', + }, + }, + }, + {}, + 'build' + ).build + ).toEqual({ + executor: 'nx:run-commands', + options: { + command: 'echo', + }, + }); + }); + + it('should support {projectRoot}, {workspaceRoot}, and {projectName} tokens', () => { + expect( + normalizeProjectTargets( + { + name: 'project', + root: 'my/project', + targets: { + build: { + executor: 'target', + options: { + a: '{projectRoot}', + b: '{workspaceRoot}', + c: '{projectName}', + }, + }, + }, + }, + {}, + 'build' + ).build.options + ).toEqual({ a: 'my/project', b: '', c: 'project' }); + }); + + it('should suppport {projectRoot} token in targetDefaults', () => { + expect( + normalizeProjectTargets( + { + name: 'project', + root: 'my/project', + targets: { + build: { + executor: 'target', + options: { + a: 'a', + }, + }, + }, + }, + { + build: { + executor: 'target', + options: { + b: '{projectRoot}', + }, + }, + }, + 'build' + ).build.options + ).toEqual({ a: 'a', b: 'my/project' }); + }); + + it('should not merge options when targets use different executors', () => { + expect( + normalizeProjectTargets( + { + root: 'my/project', + targets: { + build: { + executor: 'target', + options: { + a: 'a', + }, + }, + }, + }, + { + build: { + executor: 'different-target', + options: { + b: 'c', + }, + }, + }, + 'build' + ).build.options + ).toEqual({ a: 'a' }); + }); + + it('should not merge options when either target or target defaults use `command`', () => { + expect( + normalizeProjectTargets( + { + root: 'my/project', + targets: { + build: { + command: 'echo', + }, + }, + }, + { + build: { + executor: 'target', + options: { + b: 'c', + }, + }, + }, + 'build' + ).build.options + ).toEqual({ command: 'echo' }); + }); + }); }); diff --git a/packages/nx/src/project-graph/build-nodes/workspace-projects.ts b/packages/nx/src/project-graph/build-nodes/workspace-projects.ts index 8112a779930e22..430d22eb737959 100644 --- a/packages/nx/src/project-graph/build-nodes/workspace-projects.ts +++ b/packages/nx/src/project-graph/build-nodes/workspace-projects.ts @@ -14,12 +14,16 @@ import { ProjectGraphBuilder } from '../project-graph-builder'; import { PackageJson } from '../../utils/package-json'; import { readJsonFile } from '../../utils/fileutils'; import { NxJsonConfiguration } from '../../config/nx-json'; -import { ProjectConfiguration } from '../../config/workspace-json-project-json'; +import { + ProjectConfiguration, + TargetConfiguration, +} from '../../config/workspace-json-project-json'; import { findMatchingProjects } from '../../utils/find-matching-projects'; import { NX_PREFIX } from '../../utils/logger'; import { mergeTargetConfigurations, readTargetDefaultsForTarget, + resolveNxTokensInOptions, } from '../../config/workspaces'; export async function buildWorkspaceProjectNodes( @@ -123,7 +127,7 @@ export async function buildWorkspaceProjectNodes( /** * Apply target defaults and normalization */ -function normalizeProjectTargets( +export function normalizeProjectTargets( project: ProjectConfiguration, targetDefaults: NxJsonConfiguration['targetDefaults'], projectName: string @@ -135,33 +139,31 @@ function normalizeProjectTargets( ? 'nx:run-commands' : null; - const defaults = readTargetDefaultsForTarget( - target, - targetDefaults, - executor + const defaults = resolveCommandSyntacticSugar( + readTargetDefaultsForTarget(target, targetDefaults, executor), + `targetDefaults:${target}` + ); + + targets[target] = resolveCommandSyntacticSugar( + targets[target], + `${projectName}:${target}` ); if (defaults) { targets[target] = mergeTargetConfigurations(project, target, defaults); } - const config = targets[target]; - if (config.command) { - if (config.executor) { - throw new Error( - `${NX_PREFIX} ${projectName}: ${target} should not have executor and command both configured.` - ); - } else { - targets[target] = { - ...targets[target], - executor, - options: { - ...config.options, - command: config.command, - }, - }; - delete config.command; - } + targets[target].options = resolveNxTokensInOptions( + targets[target].options, + project, + `${projectName}:${target}` + ); + for (const configuration in targets[target].configurations ?? {}) { + targets[target].configurations[configuration] = resolveNxTokensInOptions( + targets[target].configurations[configuration], + project, + `${projectName}:${target}:${configuration}` + ); } } return targets; @@ -184,3 +186,29 @@ export function normalizeImplicitDependencies( .concat(implicitDependencies.filter((x) => x.startsWith('!'))) ); } + +function resolveCommandSyntacticSugar( + target: TargetConfiguration, + key: string +): TargetConfiguration { + const { command, ...config } = target ?? {}; + + if (!command) { + return target; + } + + if (config.executor) { + throw new Error( + `${NX_PREFIX} ${key} should not have executor and command both configured.` + ); + } else { + return { + ...config, + executor: 'nx:run-commands', + options: { + ...config.options, + command: command, + }, + }; + } +}