From 6287f0ff44862392dc04418f292c49dd646d0df3 Mon Sep 17 00:00:00 2001 From: FrozenPandaz Date: Wed, 13 Mar 2024 11:26:08 -0400 Subject: [PATCH] feat(core): add ability to add metadata to projects --- docs/generated/devkit/ProjectConfiguration.md | 14 ++ packages/cypress/src/plugins/plugin.spec.ts | 21 +++ packages/cypress/src/plugins/plugin.ts | 67 ++++--- packages/nx/src/adapter/compat.ts | 1 + .../src/config/workspace-json-project-json.ts | 4 + .../utils/project-configuration-utils.spec.ts | 171 ++++++++++++++++++ .../utils/project-configuration-utils.ts | 101 ++++++++++- 7 files changed, 349 insertions(+), 30 deletions(-) diff --git a/docs/generated/devkit/ProjectConfiguration.md b/docs/generated/devkit/ProjectConfiguration.md index a5946690f8904..4a55f30a87462 100644 --- a/docs/generated/devkit/ProjectConfiguration.md +++ b/docs/generated/devkit/ProjectConfiguration.md @@ -8,6 +8,7 @@ Project configuration - [generators](../../devkit/documents/ProjectConfiguration#generators): Object - [implicitDependencies](../../devkit/documents/ProjectConfiguration#implicitdependencies): string[] +- [metadata](../../devkit/documents/ProjectConfiguration#metadata): Object - [name](../../devkit/documents/ProjectConfiguration#name): string - [namedInputs](../../devkit/documents/ProjectConfiguration#namedinputs): Object - [projectType](../../devkit/documents/ProjectConfiguration#projecttype): ProjectType @@ -53,6 +54,19 @@ List of projects which are added as a dependency --- +### metadata + +• `Optional` **metadata**: `Object` + +#### Type declaration + +| Name | Type | +| :-------------- | :------------------------------- | +| `targetGroups?` | `Record`\<`string`, `string`[]\> | +| `technologies?` | `string`[] | + +--- + ### name • `Optional` **name**: `string` diff --git a/packages/cypress/src/plugins/plugin.spec.ts b/packages/cypress/src/plugins/plugin.spec.ts index 4d66889bab438..8b0bbd5059f1c 100644 --- a/packages/cypress/src/plugins/plugin.spec.ts +++ b/packages/cypress/src/plugins/plugin.spec.ts @@ -70,6 +70,11 @@ describe('@nx/cypress/plugin', () => { { "projects": { ".": { + "metadata": { + "technologies": [ + "cypress", + ], + }, "projectType": "application", "targets": { "e2e": { @@ -129,6 +134,11 @@ describe('@nx/cypress/plugin', () => { { "projects": { ".": { + "metadata": { + "technologies": [ + "cypress", + ], + }, "projectType": "application", "targets": { "component-test": { @@ -187,6 +197,17 @@ describe('@nx/cypress/plugin', () => { { "projects": { ".": { + "metadata": { + "targetGroups": { + ".:e2e-ci": [ + "e2e-ci--src/test.cy.ts", + "e2e-ci", + ], + }, + "technologies": [ + "cypress", + ], + }, "projectType": "application", "targets": { "e2e": { diff --git a/packages/cypress/src/plugins/plugin.ts b/packages/cypress/src/plugins/plugin.ts index 8a815da95936a..d957922c2f5bc 100644 --- a/packages/cypress/src/plugins/plugin.ts +++ b/packages/cypress/src/plugins/plugin.ts @@ -3,7 +3,10 @@ import { CreateNodes, CreateNodesContext, detectPackageManager, + joinPathFragments, + normalizePath, NxJsonConfiguration, + ProjectConfiguration, readJsonFile, TargetConfiguration, writeJsonFile, @@ -12,7 +15,6 @@ import { dirname, join, relative } from 'path'; import { getLockFileName } from '@nx/js'; -import { CypressExecutorOptions } from '../executors/cypress/cypress.impl'; import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; import { existsSync, readdirSync } from 'fs'; import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context'; @@ -30,24 +32,13 @@ export interface CypressPluginOptions { const cachePath = join(projectGraphCacheDirectory, 'cypress.hash'); const targetsCache = existsSync(cachePath) ? readTargetsCache() : {}; -const calculatedTargets: Record< - string, - Record -> = {}; +const calculatedTargets: Record = {}; -function readTargetsCache(): Record< - string, - Record> -> { +function readTargetsCache(): Record { return readJsonFile(cachePath); } -function writeTargetsToCache( - targets: Record< - string, - Record> - > -) { +function writeTargetsToCache(targets: Record) { writeJsonFile(cachePath, targets); } @@ -75,7 +66,7 @@ export const createNodes: CreateNodes = [ getLockFileName(detectPackageManager(context.workspaceRoot)), ]); - const targets = targetsCache[hash] + const { targets, ciTestingGroup } = targetsCache[hash] ? targetsCache[hash] : await buildCypressTargets( configFilePath, @@ -84,14 +75,25 @@ export const createNodes: CreateNodes = [ context ); - calculatedTargets[hash] = targets; + calculatedTargets[hash] = { targets, ciTestingGroup }; + + const project: Omit = { + projectType: 'application', + targets, + metadata: { + technologies: ['cypress'], + }, + }; + + if (ciTestingGroup) { + project.metadata.targetGroups = { + [`${projectRoot}:e2e-ci`]: ciTestingGroup, + }; + } return { projects: { - [projectRoot]: { - projectType: 'application', - targets, - }, + [projectRoot]: project, }, }; }, @@ -104,9 +106,9 @@ function getOutputs( ): string[] { function getOutput(path: string): string { if (path.startsWith('..')) { - return join('{workspaceRoot}', join(projectRoot, path)); + return joinPathFragments('{workspaceRoot}', projectRoot, path); } else { - return join('{projectRoot}', path); + return joinPathFragments('{projectRoot}', path); } } @@ -145,12 +147,17 @@ function getOutputs( return outputs; } +interface CypressTargets { + targets: Record; + ciTestingGroup: string[]; +} + async function buildCypressTargets( configFilePath: string, projectRoot: string, options: CypressPluginOptions, context: CreateNodesContext -) { +): Promise { const cypressConfig = await loadConfigFile( join(context.workspaceRoot, configFilePath) ); @@ -167,6 +174,7 @@ async function buildCypressTargets( const namedInputs = getNamedInputs(projectRoot, context); const targets: Record = {}; + let ciTestingGroup: string[] = []; if ('e2e' in cypressConfig) { targets[options.targetName] = { @@ -214,8 +222,10 @@ async function buildCypressTargets( const outputs = getOutputs(projectRoot, cypressConfig, 'e2e'); const inputs = getInputs(namedInputs); for (const file of specFiles) { - const relativeSpecFilePath = relative(projectRoot, file); + const relativeSpecFilePath = normalizePath(relative(projectRoot, file)); const targetName = options.ciTargetName + '--' + relativeSpecFilePath; + + ciTestingGroup.push(targetName); targets[targetName] = { outputs, inputs, @@ -240,6 +250,7 @@ async function buildCypressTargets( outputs, dependsOn, }; + ciTestingGroup.push(options.ciTargetName); } } @@ -254,7 +265,11 @@ async function buildCypressTargets( }; } - return targets; + if (ciTestingGroup.length === 0) { + ciTestingGroup = null; + } + + return { targets, ciTestingGroup }; } function normalizeOptions(options: CypressPluginOptions): CypressPluginOptions { diff --git a/packages/nx/src/adapter/compat.ts b/packages/nx/src/adapter/compat.ts index 05f8b8ab1b4df..d13d3c02fddce 100644 --- a/packages/nx/src/adapter/compat.ts +++ b/packages/nx/src/adapter/compat.ts @@ -44,6 +44,7 @@ export const allowedProjectExtensions = [ 'projectType', 'release', 'includedScripts', + 'metadata', ] as const; // If we pass props on the workspace that angular doesn't know about, diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index a2256eb344005..d71d2676140c1 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -111,6 +111,10 @@ export interface ProjectConfiguration { 'generator' | 'generatorOptions' >; }; + metadata?: { + technologies?: string[]; + targetGroups?: Record; + }; } export interface TargetDependencyConfig { diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts index be5e41c51e65e..c47040d1e7227 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts @@ -731,6 +731,177 @@ describe('project-configuration-utils', () => { `); }); + it('should merge release', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + release: { + version: { + generatorOptions: { + packageRoot: 'dist/libs/lib-a', + }, + }, + }, + }); + expect(rootMap.get('libs/lib-a').release).toMatchInlineSnapshot(` + { + "version": { + "generatorOptions": { + "packageRoot": "dist/libs/lib-a", + }, + }, + } + `); + }); + + describe('metadata', () => { + it('should be set if not previously defined', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + technologies: ['technology'], + targetGroups: { + group1: ['target1', 'target2'], + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap.get('libs/lib-a').metadata).toEqual({ + technologies: ['technology'], + targetGroups: { + group1: ['target1', 'target2'], + }, + }); + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'metadata.technologies': ['dummy', 'dummy.ts'], + 'metadata.targetGroups': ['dummy', 'dummy.ts'], + 'metadata.targetGroups.group1': ['dummy', 'dummy.ts'], + }); + }); + + it('should concat arrays', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + technologies: ['technology1'], + }, + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': { + 'metadata.technologies': ['existing', 'existing.ts'], + 'metadata.technologies.0': ['existing', 'existing.ts'], + }, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + technologies: ['technology2'], + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap.get('libs/lib-a').metadata).toEqual({ + technologies: ['technology1', 'technology2'], + }); + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'metadata.technologies': ['existing', 'existing.ts'], + 'metadata.technologies.0': ['existing', 'existing.ts'], + 'metadata.technologies.1': ['dummy', 'dummy.ts'], + }); + }); + + it('should concat second level arrays', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + targetGroups: { + group1: ['target1'], + }, + }, + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': { + 'metadata.targetGroups': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1.0': ['existing', 'existing.ts'], + }, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + targetGroups: { + group1: ['target2'], + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap.get('libs/lib-a').metadata).toEqual({ + targetGroups: { + group1: ['target1', 'target2'], + }, + }); + + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'metadata.targetGroups': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1.0': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1.1': ['dummy', 'dummy.ts'], + }); + + expect(sourceMap['libs/lib-a']['metadata.targetGroups']).toEqual([ + 'existing', + 'existing.ts', + ]); + expect(sourceMap['libs/lib-a']['metadata.targetGroups.group1']).toEqual( + ['existing', 'existing.ts'] + ); + expect( + sourceMap['libs/lib-a']['metadata.targetGroups.group1.0'] + ).toEqual(['existing', 'existing.ts']); + expect( + sourceMap['libs/lib-a']['metadata.targetGroups.group1.1'] + ).toEqual(['dummy', 'dummy.ts']); + }); + }); + describe('source map', () => { it('should add new project info', () => { const rootMap = new RootMapBuilder().getRootMap(); diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.ts index 08c2c5f727819..4fbf9043b5d5d 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.ts @@ -60,12 +60,23 @@ export function mergeProjectConfigurationIntoRootMap( // a project.json in which case it was already updated above. const updatedProjectConfiguration = { ...matchingProject, - ...project, }; - if (sourceMap) { - for (const property in project) { - sourceMap[`${property}`] = sourceInformation; + for (const k in project) { + if ( + ![ + 'tags', + 'implicitDependencies', + 'generators', + 'targets', + 'metadata', + 'namedInputs', + ].includes(k) + ) { + updatedProjectConfiguration[k] = project[k]; + if (sourceMap) { + sourceMap[`${k}`] = sourceInformation; + } } } @@ -76,6 +87,7 @@ export function mergeProjectConfigurationIntoRootMap( ); if (sourceMap) { + sourceMap['tags'] ??= sourceInformation; project.tags.forEach((tag) => { sourceMap[`tags.${tag}`] = sourceInformation; }); @@ -88,6 +100,7 @@ export function mergeProjectConfigurationIntoRootMap( ).concat(project.implicitDependencies); if (sourceMap) { + sourceMap['implicitDependencies'] ??= sourceInformation; project.implicitDependencies.forEach((implicitDependency) => { sourceMap[`implicitDependencies.${implicitDependency}`] = sourceInformation; @@ -100,6 +113,7 @@ export function mergeProjectConfigurationIntoRootMap( updatedProjectConfiguration.generators = { ...project.generators }; if (sourceMap) { + sourceMap['generators'] ??= sourceInformation; for (const generator in project.generators) { sourceMap[`generators.${generator}`] = sourceInformation; for (const property in project.generators[generator]) { @@ -127,6 +141,7 @@ export function mergeProjectConfigurationIntoRootMap( }; if (sourceMap) { + sourceMap['namedInputs'] ??= sourceInformation; for (const namedInput in project.namedInputs) { sourceMap[`namedInputs.${namedInput}`] = sourceInformation; } @@ -137,6 +152,9 @@ export function mergeProjectConfigurationIntoRootMap( // We merge the targets with special handling, so clear this back to the // targets as defined originally before merging. updatedProjectConfiguration.targets = matchingProject?.targets ?? {}; + if (sourceMap) { + sourceMap['targets'] ??= sourceInformation; + } // For each target defined in the new config for (const targetName in project.targets) { @@ -176,6 +194,81 @@ export function mergeProjectConfigurationIntoRootMap( } } + if (project.metadata) { + if (sourceMap) { + sourceMap['targets'] ??= sourceInformation; + } + for (const [metadataKey, value] of Object.entries({ + ...project.metadata, + })) { + const existingValue = matchingProject.metadata?.[metadataKey]; + + if (Array.isArray(value) && Array.isArray(existingValue)) { + for (const item of [...value]) { + const newLength = + updatedProjectConfiguration.metadata[metadataKey].push(item); + if (sourceMap) { + sourceMap[`metadata.${metadataKey}.${newLength - 1}`] = + sourceInformation; + } + } + } else if (Array.isArray(value) && existingValue === undefined) { + updatedProjectConfiguration.metadata ??= {}; + updatedProjectConfiguration.metadata[metadataKey] ??= value; + if (sourceMap) { + sourceMap[`metadata.${metadataKey}`] = sourceInformation; + } + for (let i = 0; i < value.length; i++) { + if (sourceMap) { + sourceMap[`metadata.${metadataKey}.${i}`] = sourceInformation; + } + } + } else if ( + typeof value === 'object' && + typeof existingValue === 'object' + ) { + for (const key in value) { + const existingValue = matchingProject.metadata?.[metadataKey]?.[key]; + + if (Array.isArray(value[key]) && Array.isArray(existingValue)) { + for (const item of value[key]) { + const i = + updatedProjectConfiguration.metadata[metadataKey][key].push( + item + ); + if (sourceMap) { + sourceMap[`metadata.${metadataKey}.${key}.${i - 1}`] = + sourceInformation; + } + } + } else { + updatedProjectConfiguration.metadata[metadataKey] = value; + if (sourceMap) { + sourceMap[`metadata.${metadataKey}`] = sourceInformation; + } + } + } + } else { + updatedProjectConfiguration.metadata[metadataKey] = value; + if (sourceMap) { + sourceMap[`metadata.${metadataKey}`] = sourceInformation; + + if (typeof value === 'object') { + for (const k in value) { + sourceMap[`metadata.${metadataKey}.${k}`] = sourceInformation; + if (Array.isArray(value[k])) { + for (let i = 0; i < value[k].length; i++) { + sourceMap[`metadata.${metadataKey}.${k}.${i}`] = + sourceInformation; + } + } + } + } + } + } + } + } + projectRootMap.set( updatedProjectConfiguration.root, updatedProjectConfiguration