diff --git a/e2e/angular/src/plugin.test.ts b/e2e/angular/src/plugin.test.ts new file mode 100644 index 0000000000..bf9b2e1910 --- /dev/null +++ b/e2e/angular/src/plugin.test.ts @@ -0,0 +1,172 @@ +import { + checkFilesExist, + cleanupProject, + getPackageManagerCommand, + getSelectedPackageManager, + isVerbose, + isVerboseE2ERun, + logInfo, + newProject, + runCLI, + runCommand, + tmpProjPath, + uniq, + updateFile, + updateJson, +} from '@nx/e2e/utils'; +import { angularCliVersion } from '@nx/workspace/src/utils/versions'; +import { ensureDirSync } from 'fs-extra'; +import { execSync } from 'node:child_process'; +import { join } from 'node:path'; + +describe('Angular Crystal Plugin', () => { + let proj: string; + + beforeAll(() => { + proj = newProject({ + packages: ['@nx/angular'], + unsetProjectNameAndRootFormat: false, + }); + + if (getSelectedPackageManager() === 'pnpm') { + updateFile( + 'pnpm-workspace.yaml', + `packages: + - 'projects/*' +` + ); + } else { + updateJson('package.json', (json) => { + json.workspaces = ['projects/*']; + return json; + }); + } + }); + + afterAll(() => cleanupProject()); + + it('should infer tasks from multiple angular.json files', () => { + const ngOrg1App1 = uniq('ng-org1-app1'); + const ngOrg1Lib1 = uniq('ng-org1-lib1'); + const org1Root = join(tmpProjPath(), 'projects', ngOrg1App1); + const ngOrg2App1 = uniq('ng-org2-app1'); + const ngOrg2Lib1 = uniq('ng-org2-lib1'); + const org2Root = join(tmpProjPath(), 'projects', ngOrg2App1); + const pmc = getPackageManagerCommand(); + + // first angular inner repo (e.g. imported with nx import) + runNgNew(ngOrg1App1, 'projects'); + // exclude scripts from nx, to prevent them to override the inferred tasks + updateJson(`projects/${ngOrg1App1}/package.json`, (json) => { + json.nx = { includedScripts: [] }; + return json; + }); + runCommand(pmc.run(`ng g @schematics/angular:library ${ngOrg1Lib1}`, ''), { + cwd: org1Root, + }); + + // second angular inner repo + runNgNew(ngOrg2App1, 'projects'); + // exclude scripts from nx + updateJson(`projects/${ngOrg2App1}/package.json`, (json) => { + json.nx = { includedScripts: [] }; + return json; + }); + runCommand(pmc.run(`ng g @schematics/angular:library ${ngOrg2Lib1}`, ''), { + cwd: org2Root, + }); + + // add Angular Crystal plugin + updateJson('nx.json', (json) => { + json.plugins ??= []; + json.plugins.push('@nx/angular/plugin'); + return json; + }); + + // check org1 tasks + + // build + runCLI(`build ${ngOrg1App1} --output-hashing none`); + checkFilesExist( + `projects/${ngOrg1App1}/dist/${ngOrg1App1}/browser/main.js` + ); + expect(runCLI(`build ${ngOrg1App1} --output-hashing none`)).toContain( + 'Nx read the output from the cache instead of running the command for 1 out of 1 tasks' + ); + runCLI(`build ${ngOrg1Lib1}`); + checkFilesExist( + `projects/${ngOrg1App1}/dist/${ngOrg1Lib1}/fesm2022/${ngOrg1Lib1}.mjs` + ); + expect(runCLI(`build ${ngOrg1Lib1}`)).toContain( + 'Nx read the output from the cache instead of running the command for 1 out of 1 tasks' + ); + + // test + expect( + runCLI( + `run-many -t test -p ${ngOrg1App1},${ngOrg1Lib1} --no-watch --browsers=ChromeHeadless` + ) + ).toContain('Successfully ran target test for 2 projects'); + expect( + runCLI( + `run-many -t test -p ${ngOrg1App1},${ngOrg1Lib1} --no-watch --browsers=ChromeHeadless` + ) + ).toContain( + 'Nx read the output from the cache instead of running the command for 2 out of 2 tasks' + ); + + // check org2 tasks + + // build + runCLI(`build ${ngOrg2App1} --output-hashing none`); + checkFilesExist( + `projects/${ngOrg2App1}/dist/${ngOrg2App1}/browser/main.js` + ); + expect(runCLI(`build ${ngOrg2App1} --output-hashing none`)).toContain( + 'Nx read the output from the cache instead of running the command for 1 out of 1 tasks' + ); + runCLI(`build ${ngOrg2Lib1}`); + checkFilesExist( + `projects/${ngOrg2App1}/dist/${ngOrg2Lib1}/fesm2022/${ngOrg2Lib1}.mjs` + ); + expect(runCLI(`build ${ngOrg2Lib1}`)).toContain( + 'Nx read the output from the cache instead of running the command for 1 out of 1 tasks' + ); + + // test + expect( + runCLI( + `run-many -t test -p ${ngOrg2App1},${ngOrg2Lib1} --no-watch --browsers=ChromeHeadless` + ) + ).toContain('Successfully ran target test for 2 projects'); + expect( + runCLI( + `run-many -t test -p ${ngOrg2App1},${ngOrg2Lib1} --no-watch --browsers=ChromeHeadless` + ) + ).toContain( + 'Nx read the output from the cache instead of running the command for 2 out of 2 tasks' + ); + }); +}); + +function runNgNew(projectName: string, cwd: string): void { + const packageManager = getSelectedPackageManager(); + const pmc = getPackageManagerCommand({ packageManager }); + + const command = `${pmc.runUninstalledPackage} @angular/cli@${angularCliVersion} new ${projectName} --package-manager=${packageManager}`; + cwd = join(tmpProjPath(), cwd); + ensureDirSync(cwd); + execSync(command, { + cwd, + stdio: isVerbose() ? 'inherit' : 'pipe', + env: process.env, + encoding: 'utf-8', + }); + + if (isVerboseE2ERun()) { + logInfo( + `NX`, + `E2E created an Angular CLI project at ${join(cwd, projectName)}` + ); + } +} diff --git a/e2e/nx/src/import.test.ts b/e2e/nx/src/import.test.ts index 11327fccf8..116839a5ee 100644 --- a/e2e/nx/src/import.test.ts +++ b/e2e/nx/src/import.test.ts @@ -4,6 +4,7 @@ import { getSelectedPackageManager, newProject, runCLI, + runCommand, updateJson, updateFile, e2eCwd, @@ -38,7 +39,11 @@ describe('Nx Import', () => { try { rmdirSync(join(tempImportE2ERoot)); } catch {} + + runCommand(`git add .`); + runCommand(`git commit -am "Update" --allow-empty`); }); + afterAll(() => cleanupProject()); it('should be able to import a vite app', () => { @@ -111,7 +116,7 @@ describe('Nx Import', () => { }); mkdirSync(join(repoPath, 'packages/a'), { recursive: true }); writeFileSync(join(repoPath, 'packages/a/README.md'), `# A`); - execSync(`git add packages/a`, { + execSync(`git add .`, { cwd: repoPath, }); execSync(`git commit -m "add package a"`, { @@ -119,7 +124,7 @@ describe('Nx Import', () => { }); mkdirSync(join(repoPath, 'packages/b'), { recursive: true }); writeFileSync(join(repoPath, 'packages/b/README.md'), `# B`); - execSync(`git add packages/b`, { + execSync(`git add .`, { cwd: repoPath, }); execSync(`git commit -m "add package b"`, { diff --git a/packages/angular/package.json b/packages/angular/package.json index cae3822813..e1b521b471 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -22,6 +22,7 @@ "./executors.json": "./executors.json", "./generators": "./generators.js", "./executors": "./executors.js", + "./plugin": "./plugin.js", "./tailwind": "./tailwind.js", "./module-federation": "./module-federation/index.js", "./src/utils": "./src/utils/index.js", diff --git a/packages/angular/plugin.ts b/packages/angular/plugin.ts new file mode 100644 index 0000000000..7b17ccd07b --- /dev/null +++ b/packages/angular/plugin.ts @@ -0,0 +1 @@ +export { createNodesV2 } from './src/plugins/plugin'; diff --git a/packages/angular/src/generators/init/init.ts b/packages/angular/src/generators/init/init.ts index c3351a339a..4cae47914c 100755 --- a/packages/angular/src/generators/init/init.ts +++ b/packages/angular/src/generators/init/init.ts @@ -1,13 +1,16 @@ import { addDependenciesToPackageJson, + createProjectGraphAsync, ensurePackage, formatFiles, + type GeneratorCallback, logger, readNxJson, - type GeneratorCallback, type Tree, } from '@nx/devkit'; +import { addPlugin } from '@nx/devkit/src/utils/add-plugin'; import { getInstalledPackageVersion, versions } from '../utils/version-utils'; +import { createNodesV2 } from '../../plugins/plugin'; import { Schema } from './schema'; export async function angularInitGenerator( @@ -17,6 +20,25 @@ export async function angularInitGenerator( ignoreAngularCacheDirectory(tree); const installTask = installAngularDevkitCoreIfMissing(tree, options); + // For Angular inference plugin, we only want it during import since our + // generators do not use `angular.json`, and `nx init` should split + // `angular.json` into multiple `project.json` files -- as this is preferred + // by most folks we've talked to. + options.addPlugin ??= process.env.NX_RUNNING_NX_IMPORT === 'true'; + + if (options.addPlugin) { + await addPlugin( + tree, + await createProjectGraphAsync(), + '@nx/angular/plugin', + createNodesV2, + { + targetNamePrefix: ['', 'angular:', 'angular-'], + }, + options.updatePackageScripts + ); + } + if (!options.skipFormat) { await formatFiles(tree); } diff --git a/packages/angular/src/generators/init/schema.d.ts b/packages/angular/src/generators/init/schema.d.ts index 483cf1ed2e..3db8f259f6 100644 --- a/packages/angular/src/generators/init/schema.d.ts +++ b/packages/angular/src/generators/init/schema.d.ts @@ -3,4 +3,7 @@ export interface Schema { skipInstall?: boolean; skipPackageJson?: boolean; keepExistingVersions?: boolean; + /* internal */ + addPlugin?: boolean; + updatePackageScripts?: boolean; } diff --git a/packages/angular/src/plugins/plugin.spec.ts b/packages/angular/src/plugins/plugin.spec.ts new file mode 100644 index 0000000000..122b8bd668 --- /dev/null +++ b/packages/angular/src/plugins/plugin.spec.ts @@ -0,0 +1,1197 @@ +import type { CreateNodesContextV2 } from '@nx/devkit'; +import { mkdirSync, rmdirSync } from 'node:fs'; +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import { createNodesV2, type AngularProjectConfiguration } from './plugin'; + +jest.mock('nx/src/utils/cache-directory', () => ({ + ...jest.requireActual('nx/src/utils/cache-directory'), + workspaceDataDirectory: 'tmp/project-graph-cache', +})); + +describe('@nx/angular/plugin', () => { + let createNodesFunction = createNodesV2[1]; + let context: CreateNodesContextV2; + let tempFs: TempFs; + + beforeEach(async () => { + mkdirSync('tmp/project-graph-cache', { recursive: true }); + tempFs = new TempFs('angular-plugin'); + context = { + nxJsonConfiguration: { + namedInputs: { + default: ['{projectRoot}/**/*'], + production: ['!{projectRoot}/**/*.spec.ts'], + }, + }, + workspaceRoot: tempFs.tempDir, + }; + }); + + afterEach(() => { + jest.resetModules(); + tempFs.cleanup(); + rmdirSync('tmp/project-graph-cache', { recursive: true }); + }); + + it('should infer tasks from multiple projects in angular.json', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace('my-app', '', { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + build: { + builder: '@angular-devkit/build-angular:browser', + options: { outputPath: 'dist/my-app' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + serve: { + builder: '@angular-devkit/build-angular:dev-server', + options: { browserTarget: 'my-app:build' }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + }, + }, + }, + }, + }); + await addAngularProjectToWorkspace( + 'my-lib', + '', + { + root: 'projects/my-lib', + sourceRoot: 'projects/my-lib/src', + projectType: 'library', + architect: { + build: { + builder: '@angular-devkit/build-angular:ng-packagr', + options: { project: 'projects/my-lib/ng-package.json' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + }, + { + 'projects/my-lib/ng-package.json': JSON.stringify({ + dest: '../../dist/my-lib', + }), + } + ); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect(nodes).toMatchInlineSnapshot(` + [ + [ + "angular.json", + { + "projects": { + ".": { + "projectType": "application", + "sourceRoot": "src", + "targets": { + "build": { + "cache": true, + "command": "ng build", + "dependsOn": [ + "^build", + ], + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "build" target for "my-app".", + "help": { + "command": "npx ng run my-app:build --help", + "example": { + "options": { + "localize": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + "outputs": [ + "{projectRoot}/dist/my-app", + ], + }, + "serve": { + "command": "ng serve", + "configurations": { + "production": { + "command": "ng run my-app:serve:production", + }, + }, + "metadata": { + "description": "Run the "serve" target for "my-app".", + "help": { + "command": "npx ng run my-app:serve --help", + "example": { + "options": { + "port": 4201, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + }, + "test": { + "cache": true, + "command": "ng test", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "@angular/cli", + "karma", + ], + }, + ], + "metadata": { + "description": "Run the "test" target for "my-app".", + "help": { + "command": "npx ng run my-app:test --help", + "example": { + "options": { + "codeCoverage": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + "outputs": [ + "{workspaceRoot}/coverage/{projectName}", + ], + }, + }, + }, + "projects/my-lib": { + "projectType": "library", + "sourceRoot": "projects/my-lib/src", + "targets": { + "build": { + "cache": true, + "command": "ng build", + "dependsOn": [ + "^build", + ], + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "build" target for "my-lib".", + "help": { + "command": "npx ng run my-lib:build --help", + "example": { + "options": { + "watch": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + "outputs": [ + "{workspaceRoot}/dist/my-lib", + ], + }, + "test": { + "cache": true, + "command": "ng test", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "@angular/cli", + "karma", + ], + }, + ], + "metadata": { + "description": "Run the "test" target for "my-lib".", + "help": { + "command": "npx ng run my-lib:test --help", + "example": { + "options": { + "codeCoverage": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + "outputs": [ + "{workspaceRoot}/coverage/{projectName}", + ], + }, + }, + }, + }, + }, + ], + ] + `); + }); + + it('should infer tasks from multiple angular.json files', async () => { + const org1Root = 'nested-ng-workspaces/org1'; + await createAngularWorkspace(org1Root); + await addAngularProjectToWorkspace('org1-app1', org1Root, { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + build: { + builder: '@angular-devkit/build-angular:browser', + options: { outputPath: 'dist/my-app' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + serve: { + builder: '@angular-devkit/build-angular:dev-server', + options: { browserTarget: 'my-app:build' }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + }, + }, + }, + }, + }); + await addAngularProjectToWorkspace( + 'org1-lib1', + org1Root, + { + root: 'projects/org1-lib1', + sourceRoot: 'projects/org1-lib1/src', + projectType: 'library', + architect: { + build: { + builder: '@angular-devkit/build-angular:ng-packagr', + options: { project: 'projects/org1-lib1/ng-package.json' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + }, + { + [`${org1Root}/projects/org1-lib1/ng-package.json`]: JSON.stringify({ + dest: '../../dist/my-lib', + }), + } + ); + + const org2Root = 'nested-ng-workspaces/org2'; + await createAngularWorkspace(org2Root); + await addAngularProjectToWorkspace('org2-app1', org2Root, { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + build: { + builder: '@angular-devkit/build-angular:browser', + options: { outputPath: 'dist/my-app' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + serve: { + builder: '@angular-devkit/build-angular:dev-server', + options: { browserTarget: 'my-app:build' }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + }, + }, + }, + }, + }); + await addAngularProjectToWorkspace( + 'org2-lib1', + org2Root, + { + root: 'projects/org2-lib1', + sourceRoot: 'projects/org2-lib1/src', + projectType: 'library', + architect: { + build: { + builder: '@angular-devkit/build-angular:ng-packagr', + options: { project: 'projects/org2-lib1/ng-package.json' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + }, + { + [`${org2Root}/projects/org2-lib1/ng-package.json`]: JSON.stringify({ + dest: '../../dist/my-lib', + }), + } + ); + + const nodes = await createNodesFunction( + [ + 'nested-ng-workspaces/org1/angular.json', + 'nested-ng-workspaces/org2/angular.json', + ], + {}, + context + ); + + expect(nodes).toMatchInlineSnapshot(` + [ + [ + "nested-ng-workspaces/org1/angular.json", + { + "projects": { + "nested-ng-workspaces/org1": { + "projectType": "application", + "sourceRoot": "nested-ng-workspaces/org1/src", + "targets": { + "build": { + "cache": true, + "command": "ng build", + "dependsOn": [ + "^build", + ], + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "build" target for "org1-app1".", + "help": { + "command": "npx ng run org1-app1:build --help", + "example": { + "options": { + "localize": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org1", + }, + "outputs": [ + "{projectRoot}/dist/my-app", + ], + }, + "serve": { + "command": "ng serve", + "configurations": { + "production": { + "command": "ng run org1-app1:serve:production", + }, + }, + "metadata": { + "description": "Run the "serve" target for "org1-app1".", + "help": { + "command": "npx ng run org1-app1:serve --help", + "example": { + "options": { + "port": 4201, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org1", + }, + }, + "test": { + "cache": true, + "command": "ng test", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "@angular/cli", + "karma", + ], + }, + ], + "metadata": { + "description": "Run the "test" target for "org1-app1".", + "help": { + "command": "npx ng run org1-app1:test --help", + "example": { + "options": { + "codeCoverage": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org1", + }, + "outputs": [ + "{workspaceRoot}/nested-ng-workspaces/org1/coverage/{projectName}", + ], + }, + }, + }, + "nested-ng-workspaces/org1/projects/org1-lib1": { + "projectType": "library", + "sourceRoot": "nested-ng-workspaces/org1/projects/org1-lib1/src", + "targets": { + "build": { + "cache": true, + "command": "ng build", + "dependsOn": [ + "^build", + ], + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "build" target for "org1-lib1".", + "help": { + "command": "npx ng run org1-lib1:build --help", + "example": { + "options": { + "watch": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org1", + }, + "outputs": [ + "{workspaceRoot}/nested-ng-workspaces/org1/dist/my-lib", + ], + }, + "test": { + "cache": true, + "command": "ng test", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "@angular/cli", + "karma", + ], + }, + ], + "metadata": { + "description": "Run the "test" target for "org1-lib1".", + "help": { + "command": "npx ng run org1-lib1:test --help", + "example": { + "options": { + "codeCoverage": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org1", + }, + "outputs": [ + "{workspaceRoot}/nested-ng-workspaces/org1/coverage/{projectName}", + ], + }, + }, + }, + }, + }, + ], + [ + "nested-ng-workspaces/org2/angular.json", + { + "projects": { + "nested-ng-workspaces/org2": { + "projectType": "application", + "sourceRoot": "nested-ng-workspaces/org2/src", + "targets": { + "build": { + "cache": true, + "command": "ng build", + "dependsOn": [ + "^build", + ], + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "build" target for "org2-app1".", + "help": { + "command": "npx ng run org2-app1:build --help", + "example": { + "options": { + "localize": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org2", + }, + "outputs": [ + "{projectRoot}/dist/my-app", + ], + }, + "serve": { + "command": "ng serve", + "configurations": { + "production": { + "command": "ng run org2-app1:serve:production", + }, + }, + "metadata": { + "description": "Run the "serve" target for "org2-app1".", + "help": { + "command": "npx ng run org2-app1:serve --help", + "example": { + "options": { + "port": 4201, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org2", + }, + }, + "test": { + "cache": true, + "command": "ng test", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "@angular/cli", + "karma", + ], + }, + ], + "metadata": { + "description": "Run the "test" target for "org2-app1".", + "help": { + "command": "npx ng run org2-app1:test --help", + "example": { + "options": { + "codeCoverage": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org2", + }, + "outputs": [ + "{workspaceRoot}/nested-ng-workspaces/org2/coverage/{projectName}", + ], + }, + }, + }, + "nested-ng-workspaces/org2/projects/org2-lib1": { + "projectType": "library", + "sourceRoot": "nested-ng-workspaces/org2/projects/org2-lib1/src", + "targets": { + "build": { + "cache": true, + "command": "ng build", + "dependsOn": [ + "^build", + ], + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "build" target for "org2-lib1".", + "help": { + "command": "npx ng run org2-lib1:build --help", + "example": { + "options": { + "watch": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org2", + }, + "outputs": [ + "{workspaceRoot}/nested-ng-workspaces/org2/dist/my-lib", + ], + }, + "test": { + "cache": true, + "command": "ng test", + "inputs": [ + "default", + "^production", + { + "externalDependencies": [ + "@angular/cli", + "karma", + ], + }, + ], + "metadata": { + "description": "Run the "test" target for "org2-lib1".", + "help": { + "command": "npx ng run org2-lib1:test --help", + "example": { + "options": { + "codeCoverage": true, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": "nested-ng-workspaces/org2", + }, + "outputs": [ + "{workspaceRoot}/nested-ng-workspaces/org2/coverage/{projectName}", + ], + }, + }, + }, + }, + }, + ], + ] + `); + }); + + it('should infer outputs correctly from ng-package.json `dest` property', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace( + 'my-lib', + '', + { + root: 'projects/my-lib', + sourceRoot: 'projects/my-lib/src', + projectType: 'library', + architect: { + build: { + builder: '@angular-devkit/build-angular:ng-packagr', + options: { project: 'projects/my-lib/ng-package.json' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + }, + { + 'projects/my-lib/ng-package.json': JSON.stringify({ + dest: '../../build-output/custom-nested/my-lib', + }), + } + ); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect( + nodes[0][1].projects['projects/my-lib'].targets.build.outputs + ).toEqual(['{workspaceRoot}/build-output/custom-nested/my-lib']); + }); + + it('should infer outputs correctly from existing ng-package.json `dest` property when `project` is a directory', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace( + 'my-lib', + '', + { + root: 'projects/my-lib', + sourceRoot: 'projects/my-lib/src', + projectType: 'library', + architect: { + build: { + builder: '@angular-devkit/build-angular:ng-packagr', + options: { project: 'projects/my-lib' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + }, + { + 'projects/my-lib/ng-package.json': JSON.stringify({ + dest: '../../build-output/custom-nested/my-lib', + }), + } + ); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect( + nodes[0][1].projects['projects/my-lib'].targets.build.outputs + ).toEqual(['{workspaceRoot}/build-output/custom-nested/my-lib']); + }); + + it('should infer outputs correctly from ng-package.js `dest` property', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace( + 'my-lib', + '', + { + root: 'projects/my-lib', + sourceRoot: 'projects/my-lib/src', + projectType: 'library', + architect: { + build: { + builder: '@angular-devkit/build-angular:ng-packagr', + options: { project: 'projects/my-lib/ng-package.js' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + }, + { + 'projects/my-lib/ng-package.js': ` + module.exports = { + dest: '../../build-output/custom-nested/my-lib', + }; + `, + } + ); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect( + nodes[0][1].projects['projects/my-lib'].targets.build.outputs + ).toEqual(['{workspaceRoot}/build-output/custom-nested/my-lib']); + }); + + it('should infer outputs correctly from existing ng-package.js `dest` property when `project` is a directory', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace( + 'my-lib', + '', + { + root: 'projects/my-lib', + sourceRoot: 'projects/my-lib/src', + projectType: 'library', + architect: { + build: { + builder: '@angular-devkit/build-angular:ng-packagr', + options: { project: 'projects/my-lib' }, + }, + test: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + }, + { + 'projects/my-lib/ng-package.js': ` + module.exports = { + dest: '../../build-output/custom-nested/my-lib', + }; + `, + } + ); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect( + nodes[0][1].projects['projects/my-lib'].targets.build.outputs + ).toEqual(['{workspaceRoot}/build-output/custom-nested/my-lib']); + }); + + it('should infer the `app-shell` task inputs & outputs from the `browserTarget` and `serverTarget` tasks', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace('my-app', '', { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + build: { + builder: '@angular-devkit/build-angular:browser', + options: { outputPath: 'dist/my-app/browser' }, + }, + serve: { + builder: '@angular-devkit/build-angular:dev-server', + options: { browserTarget: 'my-app:build' }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + }, + }, + }, + server: { + builder: '@angular-devkit/build-angular:server', + options: { outputPath: 'dist/my-app/server' }, + }, + 'app-shell': { + builder: '@angular-devkit/build-angular:app-shell', + options: { route: 'shell' }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + serverTarget: 'my-app:server:production', + }, + development: { + browserTarget: 'my-app:build:development', + serverTarget: 'my-app:server:development', + }, + }, + defaultConfiguration: 'production', + }, + }, + }); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect(nodes[0][1].projects['.'].targets['app-shell']) + .toMatchInlineSnapshot(` + { + "cache": true, + "command": "ng run my-app:app-shell", + "configurations": { + "development": { + "command": "ng run my-app:app-shell:development", + }, + "production": { + "command": "ng run my-app:app-shell:production", + }, + }, + "defaultConfiguration": "production", + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "app-shell" target for "my-app".", + "help": { + "command": "npx ng run my-app:app-shell --help", + "example": { + "options": { + "route": "/some/route", + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + "outputs": [ + "{projectRoot}/dist/my-app/browser", + "{projectRoot}/dist/my-app/server", + ], + } + `); + }); + + it('should include the `outputIndexPath` option if set in the `app-shell` task outputs', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace('my-app', '', { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + build: { + builder: '@angular-devkit/build-angular:browser', + options: { outputPath: 'dist/my-app/browser' }, + }, + serve: { + builder: '@angular-devkit/build-angular:dev-server', + options: { browserTarget: 'my-app:build' }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + }, + }, + }, + server: { + builder: '@angular-devkit/build-angular:server', + options: { outputPath: 'dist/my-app/server' }, + }, + 'app-shell': { + builder: '@angular-devkit/build-angular:app-shell', + options: { + route: 'shell', + outputIndexPath: 'dist/my-app/index.html', + }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + serverTarget: 'my-app:server:production', + }, + development: { + browserTarget: 'my-app:build:development', + serverTarget: 'my-app:server:development', + }, + }, + defaultConfiguration: 'production', + }, + }, + }); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect( + nodes[0][1].projects['.'].targets['app-shell'].outputs + ).toStrictEqual([ + '{projectRoot}/dist/my-app/browser', + '{projectRoot}/dist/my-app/server', + '{projectRoot}/dist/my-app/index.html', + ]); + }); + + it('should infer the `prerender` task inputs & outputs from the `browserTarget` and `serverTarget` tasks', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace('my-app', '', { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + build: { + builder: '@angular-devkit/build-angular:browser', + options: { outputPath: 'dist/my-app/browser' }, + }, + serve: { + builder: '@angular-devkit/build-angular:dev-server', + options: { browserTarget: 'my-app:build' }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + }, + }, + }, + server: { + builder: '@angular-devkit/build-angular:server', + options: { outputPath: 'dist/my-app/server' }, + }, + prerender: { + builder: '@angular-devkit/build-angular:prerender', + options: { routes: ['/'] }, + configurations: { + production: { + browserTarget: 'my-app:build:production', + serverTarget: 'my-app:server:production', + }, + development: { + browserTarget: 'my-app:build:development', + serverTarget: 'my-app:server:development', + }, + }, + defaultConfiguration: 'production', + }, + }, + }); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect(nodes[0][1].projects['.'].targets.prerender).toMatchInlineSnapshot(` + { + "cache": true, + "command": "ng run my-app:prerender", + "configurations": { + "development": { + "command": "ng run my-app:prerender:development", + }, + "production": { + "command": "ng run my-app:prerender:production", + }, + }, + "defaultConfiguration": "production", + "inputs": [ + "production", + "^production", + { + "externalDependencies": [ + "@angular/cli", + ], + }, + ], + "metadata": { + "description": "Run the "prerender" target for "my-app".", + "help": { + "command": "npx ng run my-app:prerender --help", + "example": { + "options": { + "discoverRoutes": false, + }, + }, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + "outputs": [ + "{projectRoot}/dist/my-app/browser", + "{projectRoot}/dist/my-app/server", + ], + } + `); + }); + + it('should infer tasks using unsupported builders', async () => { + await createAngularWorkspace(''); + await addAngularProjectToWorkspace('my-app', '', { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + 'some-target': { + builder: '@foo/bar:baz', + options: { foo: true }, + }, + }, + }); + + const nodes = await createNodesFunction(['angular.json'], {}, context); + + expect(nodes[0][1].projects['.'].targets['some-target']) + .toMatchInlineSnapshot(` + { + "command": "ng run my-app:some-target", + "metadata": { + "description": "Run the "some-target" target for "my-app".", + "help": { + "command": "npx ng run my-app:some-target --help", + "example": {}, + }, + "technologies": [ + "angular", + ], + }, + "options": { + "cwd": ".", + }, + } + `); + }); + + async function createAngularWorkspace(root: string) { + await tempFs.createFiles({ + [`${root}/package.json`]: '{}', + [`${root}/angular.json`]: JSON.stringify({ projects: {} }), + }); + } + + async function addAngularProjectToWorkspace( + project: string, + angularWorkspaceRoot: string, + projectConfiguration: AngularProjectConfiguration, + files?: Record + ) { + const angularJson = JSON.parse( + await tempFs.readFile(`${angularWorkspaceRoot}/angular.json`) + ); + angularJson.projects[project] = projectConfiguration; + tempFs.writeFile( + `${angularWorkspaceRoot}/angular.json`, + JSON.stringify(angularJson, null, 2) + ); + + if (files) { + await tempFs.createFiles(files); + } + } +}); diff --git a/packages/angular/src/plugins/plugin.ts b/packages/angular/src/plugins/plugin.ts new file mode 100644 index 0000000000..7c41b9647e --- /dev/null +++ b/packages/angular/src/plugins/plugin.ts @@ -0,0 +1,763 @@ +import { + type CreateNodesContextV2, + createNodesFromFiles, + type CreateNodesResult, + type CreateNodesV2, + detectPackageManager, + getPackageManagerCommand, + type ProjectConfiguration, + readJsonFile, + type Target, + type TargetConfiguration, + writeJsonFile, +} from '@nx/devkit'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; +import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; +import { getLockFileName } from '@nx/js'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import * as posix from 'node:path/posix'; +import { hashObject } from 'nx/src/devkit-internals'; +import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; + +export interface AngularPluginOptions { + targetNamePrefix?: string; +} + +type AngularProjects = Record< + string, + Pick +>; + +type AngularTargetConfiguration = { + builder: string; + options?: Record; + configurations?: Record; + defaultConfiguration?: string; +}; +export type AngularProjectConfiguration = { + projectType: 'application' | 'library'; + root: string; + sourceRoot?: string; + architect?: Record; + targets?: Record; +}; +type AngularJson = { projects?: Record }; + +const knownExecutors = { + appShell: new Set(['@angular-devkit/build-angular:app-shell']), + build: new Set([ + '@angular-devkit/build-angular:application', + '@angular/build:application', + '@angular-devkit/build-angular:browser-esbuild', + '@angular-devkit/build-angular:browser', + '@angular-devkit/build-angular:ng-packagr', + ]), + devServer: new Set(['@angular-devkit/build-angular:dev-server']), + extractI18n: new Set(['@angular-devkit/build-angular:extract-i18n']), + prerender: new Set([ + '@angular-devkit/build-angular:prerender', + '@nguniversal/builders:prerender', + ]), + server: new Set(['@angular-devkit/build-angular:server']), + serveSsr: new Set([ + '@angular-devkit/build-angular:ssr-dev-server', + '@nguniversal/builders:ssr-dev-server', + ]), + test: new Set(['@angular-devkit/build-angular:karma']), +}; + +const pmc = getPackageManagerCommand(); + +function readProjectsCache(cachePath: string): Record { + return existsSync(cachePath) ? readJsonFile(cachePath) : {}; +} + +function writeProjectsToCache( + cachePath: string, + results: Record +) { + writeJsonFile(cachePath, results); +} + +export const createNodesV2: CreateNodesV2 = [ + '**/angular.json', + async (configFiles, options, context) => { + const optionsHash = hashObject(options); + const cachePath = join( + workspaceDataDirectory, + `angular-${optionsHash}.hash` + ); + const projectsCache = readProjectsCache(cachePath); + try { + return await createNodesFromFiles( + (configFile, options, context) => + createNodesInternal(configFile, options, context, projectsCache), + configFiles, + options, + context + ); + } finally { + writeProjectsToCache(cachePath, projectsCache); + } + }, +]; + +async function createNodesInternal( + configFilePath: string, + options: {} | undefined, + context: CreateNodesContextV2, + projectsCache: Record +): Promise { + const angularWorkspaceRoot = dirname(configFilePath); + + // Do not create a project if package.json isn't there + const siblingFiles = readdirSync( + join(context.workspaceRoot, angularWorkspaceRoot) + ); + if (!siblingFiles.includes('package.json')) { + return {}; + } + + const hash = await calculateHashForCreateNodes( + angularWorkspaceRoot, + options, + context, + [getLockFileName(detectPackageManager(context.workspaceRoot))] + ); + + projectsCache[hash] ??= await buildAngularProjects( + configFilePath, + options, + angularWorkspaceRoot, + context + ); + + return { projects: projectsCache[hash] }; +} + +async function buildAngularProjects( + configFilePath: string, + options: AngularPluginOptions, + angularWorkspaceRoot: string, + context: CreateNodesContextV2 +): Promise { + const projects: Record = + {}; + + const absoluteConfigFilePath = join(context.workspaceRoot, configFilePath); + const angularJson = readJsonFile(absoluteConfigFilePath); + + const appShellTargets: Target[] = []; + const prerenderTargets: Target[] = []; + for (const [projectName, project] of Object.entries( + angularJson.projects ?? {} + )) { + const targets: Record = {}; + + const projectTargets = getAngularJsonProjectTargets(project); + if (!projectTargets) { + continue; + } + + const namedInputs = getNamedInputs(project.root, context); + + for (const [angularTargetName, angularTarget] of Object.entries( + projectTargets + )) { + const nxTargetName = options?.targetNamePrefix + ? `${options.targetNamePrefix}${angularTargetName}` + : angularTargetName; + const externalDependencies = ['@angular/cli']; + + targets[nxTargetName] = { + command: + // For targets that are also Angular CLI commands, infer the simplified form. + // Otherwise, use `ng run` to support non-command targets so that they will run. + angularTargetName === 'build' || + angularTargetName === 'deploy' || + angularTargetName === 'extract-i18n' || + angularTargetName === 'e2e' || + angularTargetName === 'lint' || + angularTargetName === 'serve' || + angularTargetName === 'test' + ? `ng ${angularTargetName}` + : `ng run ${projectName}:${angularTargetName}`, + options: { cwd: angularWorkspaceRoot }, + metadata: { + technologies: ['angular'], + description: `Run the "${angularTargetName}" target for "${projectName}".`, + help: { + command: `${pmc.exec} ng run ${projectName}:${angularTargetName} --help`, + example: {}, + }, + }, + }; + + if (knownExecutors.appShell.has(angularTarget.builder)) { + appShellTargets.push({ target: nxTargetName, project: projectName }); + } else if (knownExecutors.build.has(angularTarget.builder)) { + await updateBuildTarget( + nxTargetName, + targets[nxTargetName], + angularTarget, + context, + angularWorkspaceRoot, + project.root, + namedInputs + ); + } else if (knownExecutors.devServer.has(angularTarget.builder)) { + targets[nxTargetName].metadata.help.example.options = { port: 4201 }; + } else if (knownExecutors.extractI18n.has(angularTarget.builder)) { + targets[nxTargetName].metadata.help.example.options = { + format: 'json', + }; + } else if (knownExecutors.test.has(angularTarget.builder)) { + updateTestTarget( + targets[nxTargetName], + angularTarget, + context, + angularWorkspaceRoot, + project.root, + namedInputs, + externalDependencies + ); + } else if (knownExecutors.server.has(angularTarget.builder)) { + updateServerTarget( + targets[nxTargetName], + angularTarget, + context, + angularWorkspaceRoot, + project.root, + namedInputs + ); + } else if (knownExecutors.serveSsr.has(angularTarget.builder)) { + targets[nxTargetName].metadata.help.example.options = { port: 4201 }; + } else if (knownExecutors.prerender.has(angularTarget.builder)) { + prerenderTargets.push({ target: nxTargetName, project: projectName }); + } + + if (targets[nxTargetName].inputs?.length) { + targets[nxTargetName].inputs.push({ externalDependencies }); + } + + if (angularTarget.configurations) { + for (const configurationName of Object.keys( + angularTarget.configurations + )) { + targets[nxTargetName].configurations = { + ...targets[nxTargetName].configurations, + [configurationName]: { + command: `ng run ${projectName}:${angularTargetName}:${configurationName}`, + }, + }; + } + } + + if (angularTarget.defaultConfiguration) { + targets[nxTargetName].defaultConfiguration = + angularTarget.defaultConfiguration; + } + } + + projects[projectName] = { + projectType: project.projectType, + root: posix.join(angularWorkspaceRoot, project.root), + sourceRoot: project.sourceRoot + ? posix.join(angularWorkspaceRoot, project.sourceRoot) + : undefined, + targets, + }; + } + + for (const { project, target } of appShellTargets) { + updateAppShellTarget( + project, + target, + projects, + angularJson, + angularWorkspaceRoot, + context + ); + } + + for (const { project, target } of prerenderTargets) { + updatePrerenderTarget(project, target, projects, angularJson); + } + + return Object.entries(projects).reduce((acc, [projectName, project]) => { + acc[project.root] = { + projectType: project.projectType, + sourceRoot: project.sourceRoot, + targets: project.targets, + }; + return acc; + }, {} as AngularProjects); +} + +function updateAppShellTarget( + projectName: string, + targetName: string, + projects: AngularProjects, + angularJson: AngularJson, + angularWorkspaceRoot: string, + context: CreateNodesContextV2 +): void { + // it must exist since we collected it when processing it + const target = projects[projectName].targets[targetName]; + + target.metadata.help.example.options = { route: '/some/route' }; + + const { inputs, outputs } = getBrowserAndServerTargetInputsAndOutputs( + projectName, + targetName, + projects, + angularJson + ); + + const outputIndexPath = getAngularJsonProjectTargets( + angularJson.projects[projectName] + )[targetName].options?.outputIndexPath; + if (outputIndexPath) { + const fullOutputIndexPath = join( + context.workspaceRoot, + angularWorkspaceRoot, + outputIndexPath + ); + outputs.push( + getOutput( + fullOutputIndexPath, + context.workspaceRoot, + angularWorkspaceRoot, + angularJson.projects[projectName].root + ) + ); + } + + if (!outputs.length) { + // no outputs were identified for the build or server target, so we don't + // set any Nx cache options + return; + } + + target.cache = true; + target.inputs = inputs; + target.outputs = outputs; +} + +async function updateBuildTarget( + targetName: string, + target: TargetConfiguration, + angularTarget: AngularTargetConfiguration, + context: CreateNodesContextV2, + angularWorkspaceRoot: string, + projectRoot: string, + namedInputs: ReturnType +): Promise { + target.dependsOn = [`^${targetName}`]; + + if (angularTarget.options?.outputPath) { + const fullOutputPath = join( + context.workspaceRoot, + angularWorkspaceRoot, + angularTarget.options.outputPath + ); + target.outputs = [ + getOutput( + fullOutputPath, + context.workspaceRoot, + angularWorkspaceRoot, + projectRoot + ), + ]; + } else if ( + angularTarget.builder === '@angular-devkit/build-angular:ng-packagr' + ) { + const outputs = await getNgPackagrOutputs( + angularTarget, + angularWorkspaceRoot, + projectRoot, + context + ); + if (outputs.length) { + target.outputs = outputs; + } + } + + if (target.outputs?.length) { + // make it cacheable if we were able to identify outputs + target.cache = true; + target.inputs = + 'production' in namedInputs + ? ['production', '^production'] + : ['default', '^default']; + } + + if (angularTarget.builder === '@angular-devkit/build-angular:ng-packagr') { + target.metadata.help.example.options = { watch: true }; + } else { + target.metadata.help.example.options = { localize: true }; + } +} + +function updateTestTarget( + target: TargetConfiguration, + angularTarget: AngularTargetConfiguration, + context: CreateNodesContextV2, + angularWorkspaceRoot: string, + projectRoot: string, + namedInputs: ReturnType, + externalDependencies: string[] +): void { + target.cache = true; + target.inputs = + 'production' in namedInputs + ? ['default', '^production'] + : ['default', '^default']; + target.outputs = getKarmaTargetOutputs( + angularTarget, + angularWorkspaceRoot, + projectRoot, + context + ); + externalDependencies.push('karma'); + + target.metadata.help.example.options = { codeCoverage: true }; +} + +function updateServerTarget( + target: TargetConfiguration, + angularTarget: AngularTargetConfiguration, + context: CreateNodesContextV2, + angularWorkspaceRoot: string, + projectRoot: string, + namedInputs: ReturnType +): void { + target.metadata.help.example.options = { localize: true }; + + if (!angularTarget.options?.outputPath) { + // only make it cacheable if we were able to identify outputs + return; + } + + target.cache = true; + target.inputs = + 'production' in namedInputs + ? ['production', '^production'] + : ['default', '^default']; + + const fullOutputPath = join( + context.workspaceRoot, + angularWorkspaceRoot, + angularTarget.options.outputPath + ); + target.outputs = [ + getOutput( + fullOutputPath, + context.workspaceRoot, + angularWorkspaceRoot, + projectRoot + ), + ]; +} + +function updatePrerenderTarget( + projectName: string, + targetName: string, + projects: AngularProjects, + angularJson: AngularJson +): void { + // it must exist since we collected it when processing it + const target = projects[projectName].targets[targetName]; + + target.metadata.help.example.options = + getAngularJsonProjectTargets(angularJson.projects[projectName])[targetName] + .builder === '@angular-devkit/build-angular:prerender' + ? { discoverRoutes: false } + : { guessRoutes: false }; + + const { inputs, outputs } = getBrowserAndServerTargetInputsAndOutputs( + projectName, + targetName, + projects, + angularJson + ); + + if (!outputs.length) { + // no outputs were identified for the build or server target, so we don't + // set any Nx cache options + return; + } + + target.cache = true; + target.inputs = inputs; + target.outputs = outputs; +} + +async function getNgPackagrOutputs( + target: AngularTargetConfiguration, + angularWorkspaceRoot: string, + projectRoot: string, + context: CreateNodesContextV2 +): Promise { + let ngPackageJsonPath = join( + context.workspaceRoot, + angularWorkspaceRoot, + target.options.project + ); + + const readConfig = async (configPath: string) => { + if (!existsSync(configPath)) { + return undefined; + } + + try { + if (configPath.endsWith('.js')) { + const result = await import(configPath); + + return result['default'] ?? result; + } + + return readJsonFile(configPath); + } catch {} + + return undefined; + }; + + let ngPackageJson: { dest?: string }; + let basePath: string; + if (statSync(ngPackageJsonPath).isDirectory()) { + basePath = ngPackageJsonPath; + ngPackageJson = await readConfig( + join(ngPackageJsonPath, 'ng-package.json') + ); + if (!ngPackageJson) { + ngPackageJson = await readConfig( + join(ngPackageJsonPath, 'ng-package.js') + ); + } + } else { + basePath = dirname(ngPackageJsonPath); + ngPackageJson = await readConfig(ngPackageJsonPath); + } + + if (!ngPackageJson) { + return []; + } + + const destination = ngPackageJson.dest + ? join(basePath, ngPackageJson.dest) + : join(basePath, 'dist'); + + return [ + getOutput( + destination, + context.workspaceRoot, + angularWorkspaceRoot, + projectRoot + ), + ]; +} + +function getKarmaTargetOutputs( + target: AngularTargetConfiguration, + angularWorkspaceRoot: string, + projectRoot: string, + context: CreateNodesContextV2 +): string[] { + const defaultOutput = posix.join( + '{workspaceRoot}', + angularWorkspaceRoot, + 'coverage/{projectName}' + ); + if (!target.options?.karmaConfig) { + return [defaultOutput]; + } + + try { + const { parseConfig } = require('karma/lib/config'); + + const karmaConfigPath = join( + context.workspaceRoot, + angularWorkspaceRoot, + projectRoot, + target.options.karmaConfig + ); + const config = parseConfig(karmaConfigPath); + + if (config.coverageReporter.dir) { + return [ + getOutput( + config.coverageReporter.dir, + context.workspaceRoot, + angularWorkspaceRoot, + projectRoot + ), + ]; + } + } catch { + // we silently ignore any error here and fall back to the default output + } + + return [defaultOutput]; +} + +function getBrowserAndServerTargetInputsAndOutputs( + projectName: string, + targetName: string, + projects: AngularProjects, + angularJson: AngularJson +) { + const { browserTarget, serverTarget } = extractBrowserAndServerTargets( + angularJson, + projectName, + targetName + ); + if (!browserTarget || !serverTarget) { + // if any of these are missing, the target is invalid so we return empty values + return { inputs: [], outputs: [] }; + } + + const browserTargetInputs = + projects[browserTarget.project]?.targets?.[browserTarget.target]?.inputs ?? + []; + const serverTargetInputs = + projects[serverTarget.project]?.targets?.[serverTarget.target]?.inputs ?? + []; + const browserTargetOutputs = + projects[browserTarget.project]?.targets?.[browserTarget.target]?.outputs ?? + []; + const serverTargetOutputs = + projects[serverTarget.project]?.targets?.[serverTarget.target]?.outputs ?? + []; + + return { + inputs: mergeInputs(...browserTargetInputs, ...serverTargetInputs), + outputs: Array.from( + new Set([...browserTargetOutputs, ...serverTargetOutputs]) + ), + }; +} + +function extractBrowserAndServerTargets( + angularJson: AngularJson, + projectName: string, + targetName: string +): { + browserTarget: Target; + serverTarget: Target; +} { + let browserTarget: Target | undefined; + let serverTarget: Target | undefined; + + try { + const targets = getAngularJsonProjectTargets( + angularJson.projects[projectName] + ); + const target = targets[targetName]; + + let browserTargetSpecifier = target.options?.browserTarget; + if (!browserTargetSpecifier) { + const configuration = Object.values(target.configurations ?? {}).find( + (config) => !!config.browserTarget + ); + browserTargetSpecifier = configuration?.browserTarget; + } + + if (browserTargetSpecifier) { + browserTarget = targetFromTargetString( + browserTargetSpecifier, + projectName, + targetName + ); + } + + let serverTargetSpecifier = target.options?.serverTarget; + if (!serverTargetSpecifier) { + serverTargetSpecifier = Object.values(target.configurations ?? {}).find( + (config) => !!config.serverTarget + )?.serverTarget; + } + + if (serverTargetSpecifier) { + serverTarget = targetFromTargetString( + serverTargetSpecifier, + projectName, + targetName + ); + } + } catch {} + + return { browserTarget: browserTarget, serverTarget }; +} + +function mergeInputs( + ...inputs: TargetConfiguration['inputs'] +): TargetConfiguration['inputs'] { + const stringInputs = new Set(); + const externalDependencies = new Set(); + + for (const input of inputs) { + if (typeof input === 'string') { + stringInputs.add(input); + } else if ('externalDependencies' in input) { + // we only infer external dependencies, so we don't need to handle the other input definitions + for (const externalDependency of input.externalDependencies) { + externalDependencies.add(externalDependency); + } + } + } + + return [ + ...stringInputs, + ...(externalDependencies.size + ? [{ externalDependencies: Array.from(externalDependencies) }] + : []), + ]; +} + +// angular support abbreviated target specifiers, this is adapter from: +// https://github.com/angular/angular-cli/blob/7d9ce246a33c60ec96eb4bf99520f5475716a910/packages/angular_devkit/architect/src/api.ts#L336 +function targetFromTargetString( + specifier: string, + abbreviatedProjectName?: string, + abbreviatedTargetName?: string +) { + const tuple = specifier.split(':', 3); + if (tuple.length < 2) { + // invalid target, ignore + return undefined; + } + + // we only care about project and target + return { + project: tuple[0] || abbreviatedProjectName || '', + target: tuple[1] || abbreviatedTargetName || '', + }; +} + +function getOutput( + path: string, + workspaceRoot: string, + angularWorkspaceRoot: string, + projectRoot: string +): string { + const relativePath = relative( + join(workspaceRoot, angularWorkspaceRoot, projectRoot), + path + ); + if (relativePath.startsWith('..')) { + return posix.join( + '{workspaceRoot}', + join(angularWorkspaceRoot, projectRoot, relativePath) + ); + } else { + return posix.join('{projectRoot}', relativePath); + } +} + +function getAngularJsonProjectTargets( + project: AngularProjectConfiguration +): Record { + return project.architect ?? project.targets; +} diff --git a/packages/devkit/src/utils/add-plugin.ts b/packages/devkit/src/utils/add-plugin.ts index 19105b367d..f4dc447237 100644 --- a/packages/devkit/src/utils/add-plugin.ts +++ b/packages/devkit/src/utils/add-plugin.ts @@ -104,21 +104,64 @@ async function _addPluginInternal( let pluginOptions: PluginOptions; let projConfigs: ConfigurationResult; - const combinations = generateCombinations(options); - optionsLoop: for (const _pluginOptions of combinations) { - pluginOptions = _pluginOptions as PluginOptions; - nxJson.plugins ??= []; - if ( - nxJson.plugins.some((p) => - typeof p === 'string' - ? p === pluginName - : p.plugin === pluginName && !p.include - ) - ) { - // Plugin has already been added - return; + if (Object.keys(options).length > 0) { + const combinations = generateCombinations(options); + optionsLoop: for (const _pluginOptions of combinations) { + pluginOptions = _pluginOptions as PluginOptions; + + nxJson.plugins ??= []; + if ( + nxJson.plugins.some((p) => + typeof p === 'string' + ? p === pluginName + : p.plugin === pluginName && !p.include + ) + ) { + // Plugin has already been added + return; + } + global.NX_GRAPH_CREATION = true; + try { + projConfigs = await retrieveProjectConfigurations( + [pluginFactory(pluginOptions)], + tree.root, + nxJson + ); + } catch (e) { + // Errors are okay for this because we're only running 1 plugin + if (e instanceof ProjectConfigurationsError) { + projConfigs = e.partialProjectConfigurationsResult; + } else { + throw e; + } + } + global.NX_GRAPH_CREATION = false; + + for (const projConfig of Object.values(projConfigs.projects)) { + const node = graphNodes.find( + (node) => node.data.root === projConfig.root + ); + + if (!node) { + continue; + } + + for (const targetName in projConfig.targets) { + if (node.data.targets[targetName]) { + // Conflicting Target Name, check the next one + pluginOptions = null; + continue optionsLoop; + } + } + } + + break; } + } else { + // If the plugin does not take in options, we add the plugin with empty options. + nxJson.plugins ??= []; + pluginOptions = {} as unknown as PluginOptions; global.NX_GRAPH_CREATION = true; try { projConfigs = await retrieveProjectConfigurations( @@ -135,26 +178,6 @@ async function _addPluginInternal( } } global.NX_GRAPH_CREATION = false; - - for (const projConfig of Object.values(projConfigs.projects)) { - const node = graphNodes.find( - (node) => node.data.root === projConfig.root - ); - - if (!node) { - continue; - } - - for (const targetName in projConfig.targets) { - if (node.data.targets[targetName]) { - // Conflicting Target Name, check the next one - pluginOptions = null; - continue optionsLoop; - } - } - } - - break; } if (!pluginOptions) { diff --git a/packages/devkit/src/utils/calculate-hash-for-create-nodes.ts b/packages/devkit/src/utils/calculate-hash-for-create-nodes.ts index 45b8779f9b..c4bfee85dd 100644 --- a/packages/devkit/src/utils/calculate-hash-for-create-nodes.ts +++ b/packages/devkit/src/utils/calculate-hash-for-create-nodes.ts @@ -1,12 +1,16 @@ import { join } from 'path'; -import { CreateNodesContext, hashArray } from 'nx/src/devkit-exports'; +import { + CreateNodesContext, + CreateNodesContextV2, + hashArray, +} from 'nx/src/devkit-exports'; import { hashObject, hashWithWorkspaceContext } from 'nx/src/devkit-internals'; export async function calculateHashForCreateNodes( projectRoot: string, options: object, - context: CreateNodesContext, + context: CreateNodesContext | CreateNodesContextV2, additionalGlobs: string[] = [] ): Promise { return hashArray([ diff --git a/packages/devkit/src/utils/get-named-inputs.ts b/packages/devkit/src/utils/get-named-inputs.ts index e9d5ba0bdf..184ddeb79c 100644 --- a/packages/devkit/src/utils/get-named-inputs.ts +++ b/packages/devkit/src/utils/get-named-inputs.ts @@ -6,6 +6,7 @@ import type { InputDefinition } from 'nx/src/config/workspace-json-project-json' import { CreateNodesContext, + CreateNodesContextV2, ProjectConfiguration, readJsonFile, } from 'nx/src/devkit-exports'; @@ -15,7 +16,7 @@ import { */ export function getNamedInputs( directory: string, - context: CreateNodesContext + context: CreateNodesContext | CreateNodesContextV2 ): { [inputName: string]: (string | InputDefinition)[] } { const projectJsonPath = join(directory, 'project.json'); const projectJson: ProjectConfiguration = existsSync(projectJsonPath) diff --git a/packages/nx/src/command-line/import/import.ts b/packages/nx/src/command-line/import/import.ts index 117666a083..e591acc0f6 100644 --- a/packages/nx/src/command-line/import/import.ts +++ b/packages/nx/src/command-line/import/import.ts @@ -59,7 +59,15 @@ export interface ImportOptions { } export async function importHandler(options: ImportOptions) { + process.env.NX_RUNNING_NX_IMPORT = 'true'; let { sourceRepository, ref, source, destination } = options; + const destinationGitClient = new GitRepository(process.cwd()); + + if (await destinationGitClient.hasUncommittedChanges()) { + throw new Error( + `You have uncommitted changes in the destination repository. Commit or revert the changes and try again.` + ); + } output.log({ title: @@ -186,7 +194,6 @@ export async function importHandler(options: ImportOptions) { const absDestination = join(process.cwd(), destination); - const destinationGitClient = new GitRepository(process.cwd()); await assertDestinationEmpty(destinationGitClient, absDestination); const tempImportBranch = getTempImportBranch(ref); @@ -259,7 +266,8 @@ export async function importHandler(options: ImportOptions) { const { plugins, updatePackageScripts } = await detectPlugins( nxJson, - options.interactive + options.interactive, + true ); if (packageManager !== sourcePackageManager) { diff --git a/packages/nx/src/command-line/init/init-v2.ts b/packages/nx/src/command-line/init/init-v2.ts index f47a1261c3..a81d5b71f1 100644 --- a/packages/nx/src/command-line/init/init-v2.ts +++ b/packages/nx/src/command-line/init/init-v2.ts @@ -157,7 +157,7 @@ export async function initHandler(options: InitArgs): Promise { }); } -const npmPackageToPluginMap: Record = { +const npmPackageToPluginMap: Record = { // Generic JS tools eslint: '@nx/eslint', storybook: '@nx/storybook', @@ -181,7 +181,8 @@ const npmPackageToPluginMap: Record = { export async function detectPlugins( nxJson: NxJsonConfiguration, - interactive: boolean + interactive: boolean, + includeAngularCli?: boolean ): Promise<{ plugins: string[]; updatePackageScripts: boolean; @@ -214,7 +215,13 @@ export async function detectPlugins( ...packageJson.devDependencies, }; - for (const [dep, plugin] of Object.entries(npmPackageToPluginMap)) { + const _npmPackageToPluginMap = { + ...npmPackageToPluginMap, + }; + if (includeAngularCli) { + _npmPackageToPluginMap['@angular/cli'] = '@nx/angular'; + } + for (const [dep, plugin] of Object.entries(_npmPackageToPluginMap)) { if (deps[dep]) { detectedPlugins.add(plugin); } diff --git a/packages/nx/src/utils/git-utils.ts b/packages/nx/src/utils/git-utils.ts index a40af0acad..e371f7155a 100644 --- a/packages/nx/src/utils/git-utils.ts +++ b/packages/nx/src/utils/git-utils.ts @@ -45,6 +45,11 @@ export class GitRepository { .trim(); } + async hasUncommittedChanges() { + const data = await this.execAsync(`git status --porcelain`); + return data.trim() !== ''; + } + async addFetchRemote(remoteName: string, branch: string) { return await this.execAsync( `git config --add remote.${remoteName}.fetch "+refs/heads/${branch}:refs/remotes/${remoteName}/${branch}"`