diff --git a/.gitignore b/.gitignore index 73a946634..9f8744370 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ Thumbs.db **/.code-pushup # Nx workspace cache -.nx +.nx/cache +.nx/workspace-data diff --git a/e2e/nx-plugin-nx18-e2e/.eslintrc.json b/e2e/nx-plugin-nx18-e2e/.eslintrc.json new file mode 100644 index 000000000..fef34b026 --- /dev/null +++ b/e2e/nx-plugin-nx18-e2e/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "code-pushup.config*.ts"], + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parserOptions": { + "project": ["e2e/nx-plugin-nx18-e2e/tsconfig.*?.json"] + } + } + ] +} diff --git a/e2e/nx-plugin-nx18-e2e/project.json b/e2e/nx-plugin-nx18-e2e/project.json new file mode 100644 index 000000000..d89a68475 --- /dev/null +++ b/e2e/nx-plugin-nx18-e2e/project.json @@ -0,0 +1,23 @@ +{ + "name": "nx-plugin-nx18-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "e2e/nx-plugin-nx18-e2e/src", + "projectType": "application", + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["e2e/nx-plugin-nx18-e2e/**/*.ts"] + } + }, + "e2e": { + "executor": "@nx/vite:test", + "options": { + "configFile": "e2e/nx-plugin-nx18-e2e/vite.config.e2e.ts" + } + } + }, + "implicitDependencies": ["test-utils", "nx-plugin"], + "tags": ["scope:tooling", "type:e2e"] +} diff --git a/e2e/nx-plugin-nx18-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-nx18-e2e/tests/plugin-create-nodes.e2e.test.ts new file mode 100644 index 000000000..c5c74629f --- /dev/null +++ b/e2e/nx-plugin-nx18-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -0,0 +1,57 @@ +import { join } from 'node:path'; +import { afterEach, expect } from 'vitest'; +import { + nxShowProjectJson, + registerPluginInNxJson, +} from '@code-pushup/test-nx-utils'; +import { teardownTestFolder } from '@code-pushup/test-setup'; +import { executeProcess } from '@code-pushup/utils'; + +describe('nx-plugin-nx18', () => { + const project = 'my-lib'; + const envRoot = 'tmp/e2e/nx-plugin-nx18-e2e'; + const baseDir = join(envRoot, '__test__/plugin/create-nodes'); + + beforeEach(async () => { + await executeProcess({ + command: 'npx', + args: [ + '--yes', + 'create-nx-workspace@18.3.5', + '__test__/plugin/create-nodes/nxv18', + '--preset=apps', + '--appName=my-app', + '--style=none', + '--packageManager=npm', + '--interactive=false', + '--ci=skip', + ], + cwd: envRoot, + }); + await registerPluginInNxJson( + join(envRoot, 'code-pushup.config.ts'), + '@code-pushup/nx-plugin', + ); + }); + + afterEach(async () => { + await teardownTestFolder(baseDir); + }); + + it('should add configuration target dynamically in nx18', async () => { + const { code, projectJson } = await nxShowProjectJson(envRoot, project); + expect(code).toBe(0); + + expect(projectJson.targets).toStrictEqual({ + ['code-pushup--configuration']: { + configurations: {}, + executor: 'nx:run-commands', + options: { + command: `nx g @code-pushup/nx-plugin:configuration --skipTarget --targetName="code-pushup" --project="${project}"`, + }, + }, + }); + + expect(projectJson.targets).toMatchSnapshot(); + }); +}, 300000); diff --git a/e2e/nx-plugin-nx18-e2e/tsconfig.json b/e2e/nx-plugin-nx18-e2e/tsconfig.json new file mode 100644 index 000000000..f5a2f890a --- /dev/null +++ b/e2e/nx-plugin-nx18-e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/e2e/nx-plugin-nx18-e2e/tsconfig.test.json b/e2e/nx-plugin-nx18-e2e/tsconfig.test.json new file mode 100644 index 000000000..cc6383cf6 --- /dev/null +++ b/e2e/nx-plugin-nx18-e2e/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + }, + "include": [ + "vite.config.e2e.ts", + "tests/**/*.e2e.test.ts", + "tests/**/*.d.ts", + "mocks/**/*.ts" + ] +} diff --git a/e2e/nx-plugin-nx18-e2e/vite.config.e2e.ts b/e2e/nx-plugin-nx18-e2e/vite.config.e2e.ts new file mode 100644 index 000000000..368115ffd --- /dev/null +++ b/e2e/nx-plugin-nx18-e2e/vite.config.e2e.ts @@ -0,0 +1,21 @@ +/// +import { defineConfig } from 'vite'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/nx-plugin-nx18-e2e', + test: { + reporters: ['basic'], + testTimeout: 160_000, + globals: true, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + cache: { + dir: '../../node_modules/.vitest', + }, + environment: 'node', + include: ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['../../testing/test-setup/src/lib/reset.mocks.ts'], + }, +}); diff --git a/packages/nx-plugin/src/executors/cli/utils.unit.test.ts b/packages/nx-plugin/src/executors/cli/utils.unit.test.ts index 19da42b17..84ed39aad 100644 --- a/packages/nx-plugin/src/executors/cli/utils.unit.test.ts +++ b/packages/nx-plugin/src/executors/cli/utils.unit.test.ts @@ -92,7 +92,7 @@ describe('parseAutorunExecutorOptions', () => { }), ); - expect(osAgnosticPath(executorOptions.persist?.outputDir)).toBe( + expect(osAgnosticPath(executorOptions?.persist?.outputDir)).toBe( osAgnosticPath('workspaceRoot/.code-pushup/my-app'), ); }); diff --git a/packages/nx-plugin/src/plugin/caching.ts b/packages/nx-plugin/src/plugin/caching.ts new file mode 100644 index 000000000..a1b33fbfc --- /dev/null +++ b/packages/nx-plugin/src/plugin/caching.ts @@ -0,0 +1,50 @@ +import { + type ProjectConfiguration, + readJsonFile, + writeJsonFile, +} from '@nx/devkit'; +import { existsSync } from 'node:fs'; +import { hashObject } from 'nx/src/hasher/file-hasher'; + +export function cacheKey(prefix: string, hashData: Record) { + return `${prefix}-${hashObject(hashData)}`; +} + +export function getCacheRecord( + targetsCache: Record, + prefix: string, + hashData: Record, +) { + const targetCacheKey = cacheKey(prefix, hashData); + + if (targetsCache[targetCacheKey]) { + return targetsCache[targetCacheKey]; + } + return undefined; +} + +export function setCacheRecord( + targetsCache: Record, + prefix: string, + hashData: Record, + cacheData: T, +) { + const targetCacheKey = cacheKey(prefix, hashData); + + return (targetsCache[targetCacheKey] = cacheData); +} + +export function readTargetsCache( + cachePath: string, +): Record> { + return process.env.NX_CACHE_PROJECT_GRAPH !== 'false' && existsSync(cachePath) + ? readJsonFile(cachePath) + : {}; +} + +export function writeTargetsToCache( + cachePath: string, + results: Record>, +) { + writeJsonFile(cachePath, results); +} diff --git a/packages/nx-plugin/src/plugin/caching.unit.test.ts b/packages/nx-plugin/src/plugin/caching.unit.test.ts new file mode 100644 index 000000000..4ef120a02 --- /dev/null +++ b/packages/nx-plugin/src/plugin/caching.unit.test.ts @@ -0,0 +1,119 @@ +import * as hasher from 'nx/src/hasher/file-hasher'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { cacheKey, getCacheRecord, setCacheRecord } from './caching'; + +describe('cacheKey', () => { + let hashObjectSpy; + + beforeEach(() => { + hashObjectSpy = vi + .spyOn(hasher, 'hashObject') + .mockImplementation(() => '42'); + }); + + afterEach(() => { + hashObjectSpy.mockClear(); + }); + + it('should start with provided prefix', () => { + expect(cacheKey('verdaccio', {} as Record)).toMatch( + /^verdaccio-/, + ); + }); + + it('should use hashObject to generate hash', () => { + expect(cacheKey('x', { prop: 42 } as Record)).toMatch( + /[0-9]*$/, + ); + expect(hashObjectSpy).toHaveBeenCalledTimes(1); + expect(hashObjectSpy).toHaveBeenCalledWith({ prop: 42 }); + }); + + it('should generate correct hash for empty object', () => { + expect(cacheKey('x', { prop: 42 } as Record)).toBe('x-42'); + expect(hashObjectSpy).toHaveBeenCalledTimes(1); + expect(hashObjectSpy).toHaveBeenCalledWith({ prop: 42 }); + }); +}); + +describe('getCacheRecord', () => { + let hashObjectSpy; + + beforeEach(() => { + hashObjectSpy = vi + .spyOn(hasher, 'hashObject') + .mockImplementation(() => '42'); + }); + + afterEach(() => { + hashObjectSpy.mockClear(); + }); + + it('should get cached data if given', () => { + const prefix = 'verdaccio'; + const targetsCache = { + 'verdaccio-42': 'cacheData', + }; + const hashData = { prop: 42 }; + + expect(getCacheRecord(targetsCache, prefix, hashData)).toBe('cacheData'); + }); + + it('should return undefined if no cache hit', () => { + const targetsCache = {}; + const prefix = 'verdaccio'; + const hashData = { prop: 43 }; + expect(getCacheRecord(targetsCache, prefix, hashData)).toBe(undefined); + }); + + it('should call cacheKey and hashObject', () => { + const targetsCache = { + 'verdaccio-42': 'cacheData', + }; + const prefix = 'verdaccio'; + const hashData = { prop: 42 }; + const hashObjectSpy = vi.spyOn(hasher, 'hashObject'); + getCacheRecord(targetsCache, prefix, hashData); + expect(hashObjectSpy).toHaveBeenCalledTimes(1); + expect(hashObjectSpy).toHaveBeenCalledWith({ prop: 42 }); + }); +}); + +describe('setCacheRecord', () => { + let hashObjectSpy; + + beforeEach(() => { + hashObjectSpy = vi + .spyOn(hasher, 'hashObject') + .mockImplementation(() => '42'); + }); + + afterEach(() => { + hashObjectSpy.mockClear(); + }); + + it('should set cached data if given', () => { + const prefix = 'verdaccio'; + const targetsCache = {}; + const hashData = { prop: 42 }; + expect(getCacheRecord(targetsCache, prefix, hashData)).toStrictEqual( + undefined, + ); + expect( + setCacheRecord(targetsCache, prefix, hashData, { test: 41 }), + ).not.toThrowError(); + expect(getCacheRecord(targetsCache, prefix, hashData)).toStrictEqual({ + test: 41, + }); + }); + + it('should return cached data after setting', () => { + const prefix = 'verdaccio'; + const targetsCache = {}; + const hashData = { prop: 42 }; + + expect( + setCacheRecord(targetsCache, prefix, hashData, { test: 41 }), + ).toStrictEqual({ test: 41 }); + }); +}); diff --git a/packages/nx-plugin/src/plugin/constants.ts b/packages/nx-plugin/src/plugin/constants.ts index bf2e81d9f..2b117219b 100644 --- a/packages/nx-plugin/src/plugin/constants.ts +++ b/packages/nx-plugin/src/plugin/constants.ts @@ -1 +1,2 @@ +export const PLUGIN_NAME = '@code-pushup/nx-plugin'; export const CP_TARGET_NAME = 'code-pushup'; diff --git a/packages/nx-plugin/src/plugin/plugin.ts b/packages/nx-plugin/src/plugin/plugin.ts index 9129f1bd7..bb0f09846 100644 --- a/packages/nx-plugin/src/plugin/plugin.ts +++ b/packages/nx-plugin/src/plugin/plugin.ts @@ -3,32 +3,36 @@ import type { CreateNodesContext, CreateNodesResult, } from '@nx/devkit'; -import { PROJECT_JSON_FILE_NAME } from '../internal/constants.js'; -import { createTargets } from './target/targets.js'; -import type { CreateNodesOptions } from './types.js'; -import { normalizedCreateNodesContext } from './utils.js'; +import { normalizeCreateNodesOptions } from '@push-based/nx-verdaccio/src/plugin/normalize-create-nodes-options'; +import { createProjectConfiguration } from '@push-based/nx-verdaccio/src/plugin/targets/create-targets'; +import { PROJECT_JSON_FILE_NAME } from '../internal/constants'; + +type FileMatcher = `${string}${typeof PROJECT_JSON_FILE_NAME}`; +const PROJECT_JSON_FILE_GLOB = `**/${PROJECT_JSON_FILE_NAME}` as FileMatcher; // name has to be "createNodes" to get picked up by Nx -export const createNodes: CreateNodes = [ - `**/${PROJECT_JSON_FILE_NAME}`, - async ( - projectConfigurationFile: string, - createNodesOptions: unknown, - context: CreateNodesContext, - ): Promise => { - const parsedCreateNodesOptions = createNodesOptions as CreateNodesOptions; - const normalizedContext = await normalizedCreateNodesContext( - context, - projectConfigurationFile, - parsedCreateNodesOptions, - ); +export const createNodes = [ + PROJECT_JSON_FILE_GLOB, + createNodesV1Fn, +] satisfies CreateNodes; + +export async function createNodesV1Fn( + projectConfigurationFile: string, + createNodesOptions: unknown, + _: CreateNodesContext, +): Promise { + const projectJson = await loadProjectConfiguration(projectConfigurationFile); + const createOptions = normalizeCreateNodesOptions(createNodesOptions); - return { - projects: { - [normalizedContext.projectRoot]: { - targets: await createTargets(normalizedContext), - }, + const { targets } = await createProjectConfiguration( + projectJson, + createOptions, + ); + return { + projects: { + [projectJson.root]: { + targets, }, - }; - }, -]; + }, + }; +} diff --git a/packages/nx-plugin/src/plugin/plugin.unit.test.ts b/packages/nx-plugin/src/plugin/plugin.unit.test.ts index c51ebf570..db5f14a3c 100644 --- a/packages/nx-plugin/src/plugin/plugin.unit.test.ts +++ b/packages/nx-plugin/src/plugin/plugin.unit.test.ts @@ -1,45 +1,62 @@ import type { CreateNodesContext } from '@nx/devkit'; import { vol } from 'memfs'; +import { join } from 'node:path'; import { describe, expect } from 'vitest'; -import { invokeCreateNodesOnVirtualFiles } from '@code-pushup/test-nx-utils'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { PACKAGE_NAME, PROJECT_JSON_FILE_NAME } from '../internal/constants.js'; import { CP_TARGET_NAME } from './constants.js'; -import { createNodes } from './plugin.js'; +import { createNodesV1Fn } from './plugin'; -describe('@code-pushup/nx-plugin/plugin', () => { - let context: CreateNodesContext; +describe('createNodesV1Fn', () => { + const context: CreateNodesContext = { + nxJsonConfiguration: {}, + workspaceRoot: '', + }; - beforeEach(() => { - context = { - nxJsonConfiguration: {}, - workspaceRoot: '', - }; - }); + it('should normalize context of project.json with missing root property', async () => { + vol.fromJSON( + { + [PROJECT_JSON_FILE_NAME]: `${JSON.stringify({ + name: '@org/empty-root', + })}`, + }, + MEMFS_VOLUME, + ); - afterEach(() => { - vol.reset(); + await expect( + createNodesV1Fn(PROJECT_JSON_FILE_NAME, {}, context), + ).resolves.toStrictEqual({ + projects: { + ['.']: { + targets: { + [`${CP_TARGET_NAME}--configuration`]: { + command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="code-pushup" --project="@org/empty-root"`, + }, + }, + }, + }, + }); }); it('should normalize context and use it to create the configuration target on ROOT project', async () => { - const projectRoot = '.'; - const matchingFilesData = { - [`${projectRoot}/${PROJECT_JSON_FILE_NAME}`]: `${JSON.stringify({ - name: '@org/empty-root', - })}`, - }; + vol.fromJSON( + { + [PROJECT_JSON_FILE_NAME]: `${JSON.stringify({ + name: '@org/empty-root', + })}`, + }, + MEMFS_VOLUME, + ); await expect( - invokeCreateNodesOnVirtualFiles( - createNodes, - context, - {}, - { matchingFilesData }, - ), + createNodesV1Fn(PROJECT_JSON_FILE_NAME, {}, context), ).resolves.toStrictEqual({ - [projectRoot]: { - targets: { - [`${CP_TARGET_NAME}--configuration`]: { - command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="code-pushup" --project="@org/empty-root"`, + projects: { + ['.']: { + targets: { + [`${CP_TARGET_NAME}--configuration`]: { + command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="code-pushup" --project="@org/empty-root"`, + }, }, }, }, @@ -48,24 +65,25 @@ describe('@code-pushup/nx-plugin/plugin', () => { it('should normalize context and use it to create the configuration target on PACKAGE project', async () => { const projectRoot = 'apps/my-app'; - const matchingFilesData = { - [`${projectRoot}/${PROJECT_JSON_FILE_NAME}`]: `${JSON.stringify({ - name: '@org/empty-root', - })}`, - }; + vol.fromJSON( + { + [join(projectRoot, PROJECT_JSON_FILE_NAME)]: `${JSON.stringify({ + root: projectRoot, + name: '@org/empty-root', + })}`, + }, + MEMFS_VOLUME, + ); await expect( - invokeCreateNodesOnVirtualFiles( - createNodes, - context, - {}, - { matchingFilesData }, - ), + createNodesV1Fn(join(projectRoot, PROJECT_JSON_FILE_NAME), {}, context), ).resolves.toStrictEqual({ - [projectRoot]: { - targets: { - [`${CP_TARGET_NAME}--configuration`]: { - command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="code-pushup" --project="@org/empty-root"`, + projects: { + [projectRoot]: { + targets: { + [`${CP_TARGET_NAME}--configuration`]: { + command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="code-pushup" --project="@org/empty-root"`, + }, }, }, }, @@ -73,30 +91,34 @@ describe('@code-pushup/nx-plugin/plugin', () => { }); it('should create the executor target on ROOT project if configured', async () => { - const projectRoot = '.'; - const matchingFilesData = { - [`${projectRoot}/${PROJECT_JSON_FILE_NAME}`]: `${JSON.stringify({ - name: '@org/empty-root', - })}`, - [`${projectRoot}/code-pushup.config.ts`]: '{}', - }; + vol.fromJSON( + { + [PROJECT_JSON_FILE_NAME]: `${JSON.stringify({ + root: '.', + name: '@org/empty-root', + })}`, + ['code-pushup.config.ts']: '{}', + }, + MEMFS_VOLUME, + ); await expect( - invokeCreateNodesOnVirtualFiles( - createNodes, - context, + createNodesV1Fn( + PROJECT_JSON_FILE_NAME, { projectPrefix: 'cli', }, - { matchingFilesData }, + context, ), ).resolves.toStrictEqual({ - [projectRoot]: { - targets: { - [CP_TARGET_NAME]: { - executor: `${PACKAGE_NAME}:cli`, - options: { - projectPrefix: 'cli', + projects: { + ['.']: { + targets: { + [CP_TARGET_NAME]: { + executor: `${PACKAGE_NAME}:cli`, + options: { + projectPrefix: 'cli', + }, }, }, }, @@ -106,29 +128,34 @@ describe('@code-pushup/nx-plugin/plugin', () => { it('should create the executor target on PACKAGE project if configured', async () => { const projectRoot = 'apps/my-app'; - const matchingFilesData = { - [`${projectRoot}/${PROJECT_JSON_FILE_NAME}`]: `${JSON.stringify({ - name: '@org/empty-root', - })}`, - [`${projectRoot}/code-pushup.config.ts`]: '{}', - }; + vol.fromJSON( + { + [join(projectRoot, PROJECT_JSON_FILE_NAME)]: `${JSON.stringify({ + root: projectRoot, + name: '@org/empty-root', + })}`, + [join(projectRoot, 'code-pushup.config.ts')]: '{}', + }, + MEMFS_VOLUME, + ); await expect( - invokeCreateNodesOnVirtualFiles( - createNodes, - context, + createNodesV1Fn( + join(projectRoot, PROJECT_JSON_FILE_NAME), { projectPrefix: 'cli', }, - { matchingFilesData }, + context, ), ).resolves.toStrictEqual({ - [projectRoot]: { - targets: { - [CP_TARGET_NAME]: { - executor: `${PACKAGE_NAME}:cli`, - options: { - projectPrefix: 'cli', + projects: { + [projectRoot]: { + targets: { + [CP_TARGET_NAME]: { + executor: `${PACKAGE_NAME}:cli`, + options: { + projectPrefix: 'cli', + }, }, }, }, diff --git a/packages/nx-plugin/src/plugin/target/targets.ts b/packages/nx-plugin/src/plugin/target/targets.ts index 3e659688b..a0d882965 100644 --- a/packages/nx-plugin/src/plugin/target/targets.ts +++ b/packages/nx-plugin/src/plugin/target/targets.ts @@ -1,19 +1,17 @@ +import type { ProjectConfiguration } from '@nx/devkit'; import { readdir } from 'node:fs/promises'; -import { CP_TARGET_NAME } from '../constants.js'; -import type { NormalizedCreateNodesContext } from '../types.js'; -import { createConfigurationTarget } from './configuration-target.js'; -import { CODE_PUSHUP_CONFIG_REGEX } from './constants.js'; -import { createExecutorTarget } from './executor-target.js'; +import { CP_TARGET_NAME } from '../constants'; +import type { NormalizedCreateNodesOptions } from '../types'; +import { createConfigurationTarget } from './configuration-target'; +import { CODE_PUSHUP_CONFIG_REGEX } from './constants'; +import { createExecutorTarget } from './executor-target'; export async function createTargets( - normalizedContext: NormalizedCreateNodesContext, + projectConfig: ProjectConfiguration, + options: NormalizedCreateNodesOptions, ) { - const { - targetName = CP_TARGET_NAME, - bin, - projectPrefix, - } = normalizedContext.createOptions; - const rootFiles = await readdir(normalizedContext.projectRoot); + const { targetName = CP_TARGET_NAME, bin, projectPrefix } = options; + const rootFiles = await readdir(projectConfig.root); return rootFiles.some(filename => filename.match(CODE_PUSHUP_CONFIG_REGEX)) ? { [targetName]: createExecutorTarget({ bin, projectPrefix }), @@ -22,7 +20,7 @@ export async function createTargets( { [`${targetName}--configuration`]: createConfigurationTarget({ targetName, - projectName: normalizedContext.projectJson.name, + projectName: projectConfig.name, bin, }), }; diff --git a/packages/nx-plugin/src/plugin/target/targets.unit.test.ts b/packages/nx-plugin/src/plugin/target/targets.unit.test.ts index 9b730f726..781d90b0c 100644 --- a/packages/nx-plugin/src/plugin/target/targets.unit.test.ts +++ b/packages/nx-plugin/src/plugin/target/targets.unit.test.ts @@ -1,13 +1,19 @@ +import type { ProjectConfiguration } from '@nx/devkit'; import { vol } from 'memfs'; import { rm } from 'node:fs/promises'; import { afterEach, beforeEach, expect } from 'vitest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { DEFAULT_TARGET_NAME, PACKAGE_NAME } from '../../internal/constants.js'; import { CP_TARGET_NAME } from '../constants.js'; -import type { NormalizedCreateNodesContext } from '../types.js'; +import type { NormalizedCreateNodesOptions } from '../types.js'; import { createTargets } from './targets.js'; describe('createTargets', () => { + const projectName = 'plugin-my-plugin'; + const projectConfig = { + root: '.', + name: projectName, + } as ProjectConfiguration; beforeEach(async () => { // needed to have the folder present. readdir otherwise it fails vol.fromJSON( @@ -24,15 +30,8 @@ describe('createTargets', () => { }); it('should return configuration targets for project without code-pushup config', async () => { - const projectName = 'plugin-my-plugin'; await expect( - createTargets({ - projectRoot: '.', - projectJson: { - name: projectName, - }, - createOptions: {}, - } as NormalizedCreateNodesContext), + createTargets(projectConfig, {} as NormalizedCreateNodesOptions), ).resolves.toStrictEqual({ [`${CP_TARGET_NAME}--configuration`]: { command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="code-pushup" --project="${projectName}"`, @@ -41,27 +40,17 @@ describe('createTargets', () => { }); it('should return configuration targets for empty project without code-pushup config and consider targetName', async () => { - const projectName = 'plugin-my-plugin'; const targetName = 'cp'; await expect( - createTargets({ - projectRoot: '.', - projectJson: { - name: projectName, - }, - createOptions: { - targetName, - }, - } as NormalizedCreateNodesContext), + createTargets(projectConfig, { targetName }), ).resolves.toStrictEqual({ [`${targetName}--configuration`]: { - command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="cp" --project="${projectName}"`, + command: `nx g ${PACKAGE_NAME}:configuration --skipTarget --targetName="${targetName}" --project="${projectName}"`, }, }); }); it('should NOT return configuration target if code-pushup config is given', async () => { - const projectName = 'plugin-my-plugin'; vol.fromJSON( { [`code-pushup.config.ts`]: `{}`, @@ -70,15 +59,7 @@ describe('createTargets', () => { ); const targetName = 'cp'; await expect( - createTargets({ - projectRoot: '.', - projectJson: { - name: projectName, - }, - createOptions: { - targetName, - }, - } as NormalizedCreateNodesContext), + createTargets(projectConfig, { targetName }), ).resolves.toStrictEqual( expect.not.objectContaining({ [`${targetName}--configuration`]: expect.any(Object), @@ -87,7 +68,6 @@ describe('createTargets', () => { }); it('should return executor target if code-pushup config is given', async () => { - const projectName = 'plugin-my-plugin'; vol.fromJSON( { [`code-pushup.config.ts`]: `{}`, @@ -96,15 +76,7 @@ describe('createTargets', () => { ); const targetName = 'cp'; await expect( - createTargets({ - projectRoot: '.', - projectJson: { - name: projectName, - }, - createOptions: { - targetName, - }, - } as NormalizedCreateNodesContext), + createTargets(projectConfig, { targetName }), ).resolves.toStrictEqual( expect.objectContaining({ [targetName]: { @@ -115,7 +87,6 @@ describe('createTargets', () => { }); it('should return executor targets for project if configured', async () => { - const projectName = 'plugin-my-plugin'; vol.fromJSON( { [`code-pushup.config.ts`]: `{}`, @@ -123,13 +94,7 @@ describe('createTargets', () => { MEMFS_VOLUME, ); await expect( - createTargets({ - projectRoot: '.', - projectJson: { - name: projectName, - }, - createOptions: {}, - } as NormalizedCreateNodesContext), + createTargets(projectConfig, {} as NormalizedCreateNodesOptions), ).resolves.toStrictEqual({ [DEFAULT_TARGET_NAME]: { executor: '@code-pushup/nx-plugin:cli', @@ -138,7 +103,6 @@ describe('createTargets', () => { }); it('should return executor targets for configured project and use given targetName', async () => { - const projectName = 'plugin-my-plugin'; vol.fromJSON( { [`code-pushup.config.ts`]: `{}`, @@ -146,15 +110,7 @@ describe('createTargets', () => { MEMFS_VOLUME, ); await expect( - createTargets({ - projectRoot: '.', - projectJson: { - name: projectName, - }, - createOptions: { - targetName: 'cp', - }, - } as NormalizedCreateNodesContext), + createTargets(projectConfig, { targetName: 'cp' }), ).resolves.toStrictEqual({ cp: { executor: '@code-pushup/nx-plugin:cli', @@ -163,7 +119,6 @@ describe('createTargets', () => { }); it('should include projectPrefix options in executor targets if given', async () => { - const projectName = 'plugin-my-plugin'; vol.fromJSON( { [`code-pushup.config.ts`]: `{}`, @@ -171,15 +126,9 @@ describe('createTargets', () => { MEMFS_VOLUME, ); await expect( - createTargets({ - projectRoot: '.', - projectJson: { - name: projectName, - }, - createOptions: { - projectPrefix: 'cli', - }, - } as NormalizedCreateNodesContext), + createTargets(projectConfig, { + projectPrefix: 'cli', + } as NormalizedCreateNodesOptions), ).resolves.toStrictEqual({ [DEFAULT_TARGET_NAME]: expect.objectContaining({ options: { projectPrefix: 'cli' }, diff --git a/packages/nx-plugin/src/plugin/types.ts b/packages/nx-plugin/src/plugin/types.ts index 5e8d59db7..9d75dea8c 100644 --- a/packages/nx-plugin/src/plugin/types.ts +++ b/packages/nx-plugin/src/plugin/types.ts @@ -13,8 +13,13 @@ export type ProjectConfigurationWithName = WithRequired< 'name' >; +export type NormalizedCreateNodesOptions = Omit< + CreateNodesOptions, + 'targetName' +> & + Required>; export type NormalizedCreateNodesContext = CreateNodesContext & { projectJson: ProjectConfigurationWithName; projectRoot: string; - createOptions: CreateNodesOptions; + createOptions: NormalizedCreateNodesOptions; }; diff --git a/packages/nx-plugin/src/plugin/utils.ts b/packages/nx-plugin/src/plugin/utils.ts index e7a819f8d..845ed3ad4 100644 --- a/packages/nx-plugin/src/plugin/utils.ts +++ b/packages/nx-plugin/src/plugin/utils.ts @@ -1,12 +1,11 @@ -import type { CreateNodesContext } from '@nx/devkit'; -import { readFile } from 'node:fs/promises'; +import type {ProjectConfiguration} from '@nx/devkit'; +import {readFile} from 'node:fs/promises'; import * as path from 'node:path'; -import { CP_TARGET_NAME } from './constants.js'; -import type { - CreateNodesOptions, - NormalizedCreateNodesContext, - ProjectConfigurationWithName, -} from './types.js'; +import {dirname, join} from 'node:path'; +import {createTargets} from './target/targets'; +import type {CreateNodesOptions, NormalizedCreateNodesOptions} from './types'; +import {CP_TARGET_NAME} from './constants.js'; +import type {CreateNodesOptions, NormalizedCreateNodesContext,} from './types.js'; export async function normalizedCreateNodesContext( context: CreateNodesContext, @@ -15,24 +14,45 @@ export async function normalizedCreateNodesContext( ): Promise { const projectRoot = path.dirname(projectConfigurationFile); - try { - const projectJson = JSON.parse( - (await readFile(projectConfigurationFile)).toString(), - ) as ProjectConfigurationWithName; +export function normalizeCreateNodesOptions( + options: unknown = {}, +): NormalizedCreateNodesOptions { + const { targetName = CP_TARGET_NAME } = options as CreateNodesOptions; + return { + ...(options as CreateNodesOptions), + targetName, + }; +} - const { targetName = CP_TARGET_NAME } = createOptions; - return { - ...context, - projectJson, - projectRoot, - createOptions: { - ...createOptions, - targetName, - }, - }; - } catch { - throw new Error( - `Error parsing project.json file ${projectConfigurationFile}.`, - ); +export async function loadProjectConfiguration( + projectConfigurationFile: string, +): Promise { + const projectConfiguration = (await readFile( + join(process.cwd(), projectConfigurationFile), + 'utf8', + ).then(JSON.parse)) as Omit & { root?: string }; + if ( + !('name' in projectConfiguration) || + typeof projectConfiguration.name !== 'string' + ) { + throw new Error('Project name is required'); } + return { + ...projectConfiguration, + root: projectConfiguration.root ?? dirname(projectConfigurationFile), + }; +} + +export async function createProjectConfiguration( + projectConfiguration: ProjectConfiguration, + options: CreateNodesOptions, +): Promise< + Pick & + Partial> +> { + const normalizeOptions = normalizeCreateNodesOptions(options); + return { + namedInputs: {}, + targets: await createTargets(projectConfiguration, normalizeOptions), + }; } diff --git a/packages/nx-plugin/src/plugin/utils.unit.test.ts b/packages/nx-plugin/src/plugin/utils.unit.test.ts index edf2bf1cb..36c2a74de 100644 --- a/packages/nx-plugin/src/plugin/utils.unit.test.ts +++ b/packages/nx-plugin/src/plugin/utils.unit.test.ts @@ -1,160 +1,121 @@ import { vol } from 'memfs'; import { describe, expect } from 'vitest'; -import { createNodesContext } from '@code-pushup/test-nx-utils'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import { normalizedCreateNodesContext } from './utils.js'; +import * as targets from './target/targets'; +import { + createProjectConfiguration, + loadProjectConfiguration, + normalizeCreateNodesOptions, +} from './utils'; -describe('normalizedCreateNodesContext', () => { - it('should provide workspaceRoot', async () => { - vol.fromJSON( - { - 'project.json': JSON.stringify({ name: 'my-project' }), - }, - MEMFS_VOLUME, - ); - - await expect( - normalizedCreateNodesContext( - createNodesContext({ workspaceRoot: MEMFS_VOLUME }), - 'project.json', - ), - ).resolves.toStrictEqual( - expect.objectContaining({ - workspaceRoot: MEMFS_VOLUME, - }), - ); +describe('normalizeCreateNodesOptions', () => { + it('should provide default targetName in options', () => { + expect(normalizeCreateNodesOptions({})).toStrictEqual({ + targetName: 'code-pushup', + }); }); - it('should provide projectRoot', async () => { - vol.fromJSON( - { - 'packages/utils/project.json': JSON.stringify({ - name: 'my-project', - }), - }, - MEMFS_VOLUME, - ); - - await expect( - normalizedCreateNodesContext( - createNodesContext(), - 'packages/utils/project.json', - ), - ).resolves.toStrictEqual( - expect.objectContaining({ - projectRoot: 'packages/utils', + it('should use provided options', () => { + expect( + normalizeCreateNodesOptions({ + targetName: 'cp', + projectPrefix: 'cli', }), - ); + ).toStrictEqual({ + targetName: 'cp', + projectPrefix: 'cli', + }); }); +}); - it('should provide nxJsonConfiguration', async () => { +describe('loadProjectConfiguration', () => { + it('should load project configuration', async () => { vol.fromJSON( { - 'project.json': JSON.stringify({ - name: 'my-project', - }), - }, - MEMFS_VOLUME, - ); - - await expect( - normalizedCreateNodesContext( - createNodesContext({ - nxJsonConfiguration: { - workspaceLayout: { - libsDir: 'libs', - }, + ['project.json']: JSON.stringify( + { + root: '.', + name: 'my-lib', }, - }), - 'project.json', - ), - ).resolves.toStrictEqual( - expect.objectContaining({ - nxJsonConfiguration: { - workspaceLayout: { - libsDir: 'libs', - }, - }, - }), - ); - }); - - it('should provide projectJson', async () => { - vol.fromJSON( - { - 'project.json': JSON.stringify({ - name: 'my-project', - }), + null, + 2, + ), }, MEMFS_VOLUME, ); - await expect( - normalizedCreateNodesContext(createNodesContext(), 'project.json'), - ).resolves.toStrictEqual( - expect.objectContaining({ - projectJson: { - name: 'my-project', - }, - }), - ); + loadProjectConfiguration('./project.json'), + ).resolves.toStrictEqual({ + root: '.', + name: 'my-lib', + }); }); - it('should throw for empty project.json', async () => { + it('should load project configuration and provide fallback for root if not given', async () => { vol.fromJSON( { - 'project.json': '', + ['packages/project.json']: JSON.stringify( + { + name: 'my-lib', + }, + null, + 2, + ), }, MEMFS_VOLUME, ); - await expect( - normalizedCreateNodesContext(createNodesContext(), 'project.json'), - ).rejects.toThrow('Error parsing project.json file project.json.'); + loadProjectConfiguration('./packages/project.json'), + ).resolves.toStrictEqual({ + root: './packages', + name: 'my-lib', + }); }); +}); - it('should provide default targetName in createOptions', async () => { +describe('createProjectConfiguration', () => { + it('should create project configuration', async () => { + const root = '.'; vol.fromJSON( { - 'project.json': JSON.stringify({ - name: 'my-project', - }), + ['project.json']: JSON.stringify( + { + root, + name: 'my-lib', + }, + null, + 2, + ), }, MEMFS_VOLUME, ); await expect( - normalizedCreateNodesContext(createNodesContext(), 'project.json'), - ).resolves.toStrictEqual( - expect.objectContaining({ - createOptions: { - targetName: 'code-pushup', + createProjectConfiguration( + { + root, + name: 'my-lib', }, - }), - ); + {}, + ), + ).resolves.toStrictEqual({ + namedInputs: {}, + targets: expect.any(Object), + }); }); - it('should provide createOptions', async () => { - vol.fromJSON( - { - 'project.json': JSON.stringify({ - name: 'my-project', - }), - }, - MEMFS_VOLUME, - ); - - await expect( - normalizedCreateNodesContext(createNodesContext(), 'project.json', { - projectPrefix: 'cli', - }), - ).resolves.toStrictEqual( - expect.objectContaining({ - createOptions: { - targetName: 'code-pushup', - projectPrefix: 'cli', - }, - }), - ); + it('should normalize options and pass project configuration and options to createTargets', async () => { + const createTargetsSpy = vi + .spyOn(targets, 'createTargets') + .mockResolvedValue({ proj: {} }); + const projectCfg = { + root: '.', + name: 'my-lib', + }; + await createProjectConfiguration(projectCfg, {}); + expect(createTargetsSpy).toHaveBeenCalledTimes(1); + expect(createTargetsSpy).toHaveBeenCalledWith(projectCfg, { + targetName: 'code-pushup', + }); }); }); diff --git a/testing/test-nx-utils/src/lib/utils/nx-plugin.ts b/testing/test-nx-utils/src/lib/utils/nx-plugin.ts index 30d9706ba..000cdecc9 100644 --- a/testing/test-nx-utils/src/lib/utils/nx-plugin.ts +++ b/testing/test-nx-utils/src/lib/utils/nx-plugin.ts @@ -1,59 +1,4 @@ -import type { - CreateNodes, - CreateNodesContext, - CreateNodesContextV2, - CreateNodesResult, -} from '@nx/devkit'; -import { vol } from 'memfs'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; - -/** - * Unit Testing helper for the createNodes function of a Nx plugin. - * This function will create files over `memfs` from testCfg.matchingFilesData - * and invoke the createNodes function on each of the files provided including potential createNodeOptions. - * It will aggregate the results of each invocation and return the projects from CreateNodesResult. - * - * @example - * ```ts - * const projects = await createFilesAndInvokeCreateNodesOnThem(createNodes, context, undefined, { matchingFilesData}); - * // project should have one target created - * const targets = projects[projectRoot]?.targets ?? {}; - * expect(Object.keys(targets)).toHaveLength(1); - * // target should be the init target - * expect(targets[`${CP_TARGET_NAME}--init`]).toBeDefined(); - * ``` - * - * @param createNodes - * @param context - * @param createNodeOptions - * @param mockData - */ -export async function invokeCreateNodesOnVirtualFiles< - T extends Record | undefined, ->( - // FIXME: refactor this to use the V2 api & remove the eslint disable on the whole file - createNodes: CreateNodes, - context: CreateNodesContext, - createNodeOptions: T, - mockData: { - matchingFilesData: Record; - }, -) { - const { matchingFilesData } = mockData; - vol.fromJSON(matchingFilesData, MEMFS_VOLUME); - - const results = await Promise.all( - Object.keys(matchingFilesData).map(file => - createNodes[1](file, createNodeOptions, context), - ), - ); - - const result: NonNullable = {}; - return results.reduce( - (acc, { projects }) => ({ ...acc, ...projects }), - result, - ); -} +import type { CreateNodesContext } from '@nx/devkit'; export function createNodesContext( options?: Partial, diff --git a/testing/test-nx-utils/src/lib/utils/nx-plugin.unit.test.ts b/testing/test-nx-utils/src/lib/utils/nx-plugin.unit.test.ts index 68a9d5daa..d1f533304 100644 --- a/testing/test-nx-utils/src/lib/utils/nx-plugin.unit.test.ts +++ b/testing/test-nx-utils/src/lib/utils/nx-plugin.unit.test.ts @@ -1,5 +1,6 @@ import * as process from 'node:process'; import { describe, expect } from 'vitest'; +import { createNodesContext } from './nx-plugin'; import { createNodesContext, invokeCreateNodesOnVirtualFiles, @@ -25,37 +26,3 @@ describe('createNodesContext', () => { }); }); }); - -describe('invokeCreateNodesOnVirtualFiles', () => { - it('should invoke passed function if matching file is given', async () => { - const createNodesFnSpy = vi.fn().mockResolvedValue({}); - await expect( - invokeCreateNodesOnVirtualFiles( - [`**/project.json`, createNodesFnSpy], - createNodesContext(), - {}, - { - matchingFilesData: { - '**/project.json': JSON.stringify({ - name: 'my-lib', - }), - }, - }, - ), - ).resolves.toStrictEqual({}); - expect(createNodesFnSpy).toHaveBeenCalledTimes(1); - }); - - it('should NOT invoke passed function if matching file is NOT given', async () => { - const createNodesFnSpy = vi.fn().mockResolvedValue({}); - await expect( - invokeCreateNodesOnVirtualFiles( - [`**/project.json`, createNodesFnSpy], - createNodesContext(), - {}, - { matchingFilesData: {} }, - ), - ).resolves.toStrictEqual({}); - expect(createNodesFnSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/testing/test-nx-utils/src/lib/utils/nx.ts b/testing/test-nx-utils/src/lib/utils/nx.ts index e6e700068..b38229f7f 100644 --- a/testing/test-nx-utils/src/lib/utils/nx.ts +++ b/testing/test-nx-utils/src/lib/utils/nx.ts @@ -4,10 +4,12 @@ import { type PluginConfiguration, type ProjectConfiguration, type Tree, + readJson, updateJson, } from '@nx/devkit'; import { libraryGenerator } from '@nx/js'; import type { LibraryGeneratorSchema } from '@nx/js/src/utils/schema'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace'; import { executeProcess } from '@code-pushup/utils'; @@ -76,6 +78,28 @@ export function registerPluginInWorkspace( })); } +export async function registerPluginInNxJson( + nxJsonPath: string, + configuration: PluginConfiguration, +) { + const normalizedPluginConfiguration = + typeof configuration === 'string' + ? { + plugin: configuration, + } + : configuration; + const json = JSON.parse( + (await readFile(nxJsonPath)).toString(), + ) as NxJsonConfiguration; + await writeFile( + nxJsonPath, + JSON.stringify({ + ...json, + plugins: [...(json.plugins ?? []), normalizedPluginConfiguration], + }), + ); +} + export async function nxShowProjectJson( cwd: string, project: string,