diff --git a/packages/core/src/lib/upload.ts b/packages/core/src/lib/upload.ts index 168566eb0..6c5b77f79 100644 --- a/packages/core/src/lib/upload.ts +++ b/packages/core/src/lib/upload.ts @@ -14,6 +14,7 @@ export type UploadOptions = { upload?: UploadConfig } & { /** * Uploads collected audits to the portal * @param options + * @param uploadFn */ export async function upload( options: UploadOptions, diff --git a/packages/nx-plugin/README.md b/packages/nx-plugin/README.md index 171222ccd..3999f5d8a 100644 --- a/packages/nx-plugin/README.md +++ b/packages/nx-plugin/README.md @@ -4,7 +4,8 @@ #### Init -Install JS packages and register plugin +Install JS packages and register plugin. +See [init docs](./src/generators/init/README.md) for details Examples: @@ -13,7 +14,8 @@ Examples: #### Configuration -Adds a `code-pushup` target to your `project.json` +Adds a `code-pushup` target to your `project.json`. +See [configuration docs](./src/generators/configuration/README.md) for details Examples: diff --git a/packages/nx-plugin/mock/fixtures/env.ts b/packages/nx-plugin/mock/fixtures/env.ts new file mode 100644 index 000000000..09297696e --- /dev/null +++ b/packages/nx-plugin/mock/fixtures/env.ts @@ -0,0 +1,7 @@ +export const ENV = { + CP_SERVER: 'https://portal.code.pushup.dev', + CP_ORGANIZATION: 'code-pushup', + CP_PROJECT: 'utils', + CP_API_KEY: '23456789098765432345678909876543', + CP_TIMEOUT: '9', +}; diff --git a/packages/nx-plugin/package.json b/packages/nx-plugin/package.json index 0330154e3..e24705a7a 100644 --- a/packages/nx-plugin/package.json +++ b/packages/nx-plugin/package.json @@ -5,7 +5,9 @@ "dependencies": { "@nx/devkit": "^17.1.3", "tslib": "2.6.2", - "nx": "^17.1.3" + "nx": "^17.1.3", + "@code-pushup/models": "*", + "zod": "^3.22.4" }, "type": "commonjs", "main": "./src/index.js", diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts new file mode 100644 index 000000000..d99ebf769 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -0,0 +1,60 @@ +export function createCliCommand( + command: string, + args: Record, +): string { + return `npx @code-pushup/cli ${command} ${objectToCliArgs(args).join(' ')}`; +} + +type ArgumentValue = number | string | boolean | string[]; +export type CliArgsObject> = + T extends never + ? // eslint-disable-next-line @typescript-eslint/naming-convention + Record | { _: string } + : T; +// @TODO import from @code-pushup/utils => get rid of poppins for cjs support +// eslint-disable-next-line sonarjs/cognitive-complexity +export function objectToCliArgs< + T extends object = Record, +>(params?: CliArgsObject): string[] { + if (!params) { + return []; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Object.entries(params).flatMap(([key, value]) => { + // process/file/script + if (key === '_') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Array.isArray(value) ? value : [`${value}`]; + } + const prefix = key.length === 1 ? '-' : '--'; + // "-*" arguments (shorthands) + if (Array.isArray(value)) { + return value.map(v => `${prefix}${key}="${v}"`); + } + + if (typeof value === 'object') { + return Object.entries(value as Record).map( + ([k, v]) => `${prefix}${key}.${k}="${v?.toString()}"`, + ); + } + + if (typeof value === 'string') { + return [`${prefix}${key}="${value}"`]; + } + + if (typeof value === 'number') { + return [`${prefix}${key}=${value}`]; + } + + if (typeof value === 'boolean') { + return [`${prefix}${value ? '' : 'no-'}${key}`]; + } + + if (value === undefined) { + return []; + } + + throw new Error(`Unsupported type ${typeof value} for key ${key}`); + }); +} diff --git a/packages/nx-plugin/src/executors/internal/cli.unit.test.ts b/packages/nx-plugin/src/executors/internal/cli.unit.test.ts new file mode 100644 index 000000000..f20620db9 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/cli.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { createCliCommand, objectToCliArgs } from './cli'; + +describe('objectToCliArgs', () => { + it('should empty params', () => { + const result = objectToCliArgs(); + expect(result).toEqual([]); + }); + + it('should handle the "_" argument as script', () => { + const params = { _: 'bin.js' }; + const result = objectToCliArgs(params); + expect(result).toEqual(['bin.js']); + }); + + it('should handle the "_" argument with multiple values', () => { + const params = { _: ['bin.js', '--help'] }; + const result = objectToCliArgs(params); + expect(result).toEqual(['bin.js', '--help']); + }); + + it('should handle shorthands arguments', () => { + const params = { + e: `test`, + }; + const result = objectToCliArgs(params); + expect(result).toEqual([`-e="${params.e}"`]); + }); + + it('should handle string arguments', () => { + const params = { name: 'Juanita' }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--name="Juanita"']); + }); + + it('should handle number arguments', () => { + const params = { parallel: 5 }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--parallel=5']); + }); + + it('should handle boolean arguments', () => { + const params = { progress: true }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--progress']); + }); + + it('should handle negated boolean arguments', () => { + const params = { progress: false }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--no-progress']); + }); + + it('should handle array of string arguments', () => { + const params = { format: ['json', 'md'] }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--format="json"', '--format="md"']); + }); + + it('should handle objects', () => { + const params = { format: { json: 'simple' } }; + const result = objectToCliArgs(params); + expect(result).toStrictEqual(['--format.json="simple"']); + }); + + it('should handle objects with undefined', () => { + const params = { format: undefined }; + const result = objectToCliArgs(params); + expect(result).toStrictEqual([]); + }); + + it('should throw error for unsupported type', () => { + expect(() => objectToCliArgs({ param: Symbol('') })).toThrow( + 'Unsupported type', + ); + }); +}); + +describe('createCliCommand', () => { + it('should create command out of command name and an object for arguments', () => { + const result = createCliCommand('autorun', { verbose: true }); + expect(result).toBe('npx @code-pushup/cli autorun --verbose'); + }); +}); diff --git a/packages/nx-plugin/src/executors/internal/config.integration.test.ts b/packages/nx-plugin/src/executors/internal/config.integration.test.ts new file mode 100644 index 000000000..82a7cb916 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/config.integration.test.ts @@ -0,0 +1,45 @@ +import { MockInstance, describe, expect } from 'vitest'; +import { ENV } from '../../../mock/fixtures/env'; +import { uploadConfig } from './config'; +import * as env from './env'; + +describe('uploadConfig', () => { + let parseEnvSpy: MockInstance<[], NodeJS.ProcessEnv>; + let processEnvSpy: MockInstance<[], NodeJS.ProcessEnv>; + + beforeAll(() => { + processEnvSpy = vi.spyOn(process, 'env', 'get').mockReturnValue({}); + parseEnvSpy = vi.spyOn(env, 'parseEnv'); + }); + + afterAll(() => { + processEnvSpy.mockRestore(); + parseEnvSpy.mockRestore(); + }); + + it('should call parseEnv function with values from process.env', () => { + processEnvSpy.mockReturnValue(ENV); + expect( + uploadConfig( + {}, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: 'my-app', + root: 'root', + }, + }, + ), + ).toEqual( + expect.objectContaining({ + server: ENV.CP_SERVER, + apiKey: ENV.CP_API_KEY, + organization: ENV.CP_ORGANIZATION, + project: ENV.CP_PROJECT, + }), + ); + + expect(parseEnvSpy).toHaveBeenCalledTimes(1); + expect(parseEnvSpy).toHaveBeenCalledWith(ENV); + }); +}); diff --git a/packages/nx-plugin/src/executors/internal/config.ts b/packages/nx-plugin/src/executors/internal/config.ts new file mode 100644 index 000000000..eb8d61a8d --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/config.ts @@ -0,0 +1,69 @@ +import { join } from 'node:path'; +import type { PersistConfig, UploadConfig } from '@code-pushup/models'; +import { parseEnv } from './env'; +import { + BaseNormalizedExecutorContext, + GlobalExecutorOptions, + ProjectExecutorOnlyOptions, +} from './types'; + +export function globalConfig( + options: Partial, + context: BaseNormalizedExecutorContext, +): Required { + const { projectConfig } = context; + const { root: projectRoot = '' } = projectConfig ?? {}; + // For better debugging use `--verbose --no-progress` as default + const { verbose, progress, config } = options; + return { + verbose: !!verbose, + progress: !!progress, + config: config ?? join(projectRoot, 'code-pushup.config.ts'), + }; +} + +export function persistConfig( + options: Partial, + context: BaseNormalizedExecutorContext, +): Partial { + const { projectConfig, workspaceRoot } = context; + + const { name: projectName = '' } = projectConfig ?? {}; + const { + format, + outputDir = join(workspaceRoot, '.code-pushup', projectName), // always in /.code-pushup/, + filename, + } = options; + + return { + outputDir, + ...(format ? { format } : {}), + ...(filename ? { filename } : {}), + }; +} + +export function uploadConfig( + options: Partial, + context: BaseNormalizedExecutorContext, +): Partial { + const { projectConfig, workspaceRoot } = context; + + const { name: projectName } = projectConfig ?? {}; + const { projectPrefix, server, apiKey, organization, project, timeout } = + options; + const applyPrefix = workspaceRoot === '.'; + const prefix = projectPrefix ? `${projectPrefix}-` : ''; + return { + ...(projectName + ? { + project: applyPrefix ? `${prefix}${projectName}` : projectName, // provide correct project + } + : {}), + ...parseEnv(process.env), + ...Object.fromEntries( + Object.entries({ server, apiKey, organization, project, timeout }).filter( + ([_, v]) => v !== undefined, + ), + ), + }; +} diff --git a/packages/nx-plugin/src/executors/internal/config.unit.test.ts b/packages/nx-plugin/src/executors/internal/config.unit.test.ts new file mode 100644 index 000000000..c23015698 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/config.unit.test.ts @@ -0,0 +1,394 @@ +import { MockInstance, describe, expect } from 'vitest'; +import { toNormalizedPath } from '@code-pushup/test-utils'; +import { ENV } from '../../../mock/fixtures/env'; +import { globalConfig, persistConfig, uploadConfig } from './config'; + +describe('globalConfig', () => { + it('should provide default global verbose options', () => { + expect( + globalConfig( + {}, + { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: 'my-app', + root: 'packages/project-root', + }, + }, + ), + ).toEqual(expect.objectContaining({ verbose: false })); + }); + + it('should parse global verbose options', () => { + expect( + globalConfig( + { verbose: true }, + { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: 'my-app', + root: 'packages/project-root', + }, + }, + ), + ).toEqual(expect.objectContaining({ verbose: true })); + }); + + it('should provide default global progress options', () => { + expect( + globalConfig( + {}, + { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: 'my-app', + root: 'packages/project-root', + }, + }, + ), + ).toEqual(expect.objectContaining({ progress: false })); + }); + + it('should parse global progress options', () => { + expect( + globalConfig( + { progress: true }, + { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: 'my-app', + root: 'packages/project-root', + }, + }, + ), + ).toEqual(expect.objectContaining({ progress: true })); + }); + + it('should provide default global config options', () => { + const { config } = globalConfig( + {}, + { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: 'my-app', + root: 'packages/project-root', + }, + }, + ); + expect(toNormalizedPath(config)).toEqual( + expect.stringContaining( + toNormalizedPath('project-root/code-pushup.config.ts'), + ), + ); + }); + + it('should parse global config options', () => { + expect( + globalConfig( + { config: 'my.config.ts' }, + { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: 'my-app', + root: 'packages/project-root', + }, + }, + ), + ).toEqual(expect.objectContaining({ config: 'my.config.ts' })); + }); + + it('should work with empty projectConfig', () => { + expect( + globalConfig( + {}, + { + workspaceRoot: '/test/root/workspace-root', + }, + ), + ).toEqual(expect.objectContaining({ config: 'code-pushup.config.ts' })); + }); + + it('should exclude other options', () => { + expect( + globalConfig({ test: 42 } as unknown as { verbose: boolean }, { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: 'my-app', + root: 'packages/project-root', + }, + }), + ).toEqual(expect.not.objectContaining({ test: expect.anything() })); + }); +}); + +describe('persistConfig', () => { + it('should NOT provide default persist format options', () => { + expect( + persistConfig( + {}, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: 'my-app', + root: 'root', + }, + }, + ), + ).toEqual(expect.not.objectContaining({ format: expect.anything() })); + }); + + it('should parse given persist format option', () => { + expect( + persistConfig( + { + format: ['md'], + }, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: 'name', + root: 'root', + }, + }, + ), + ).toEqual( + expect.objectContaining({ + format: ['md'], + }), + ); + }); + + it('should provide default outputDir options', () => { + const projectName = 'my-app'; + const { outputDir } = persistConfig( + {}, + { + workspaceRoot: '/test/root/workspace-root', + projectConfig: { + name: projectName, + root: 'packages/project-root', + }, + }, + ); + expect(toNormalizedPath(outputDir)).toEqual( + expect.stringContaining( + toNormalizedPath( + `/test/root/workspace-root/.code-pushup/${projectName}`, + ), + ), + ); + }); + + it('should parse given outputDir options', () => { + const outputDir = '../dist/packages/test-folder'; + const { outputDir: resultingOutDir } = persistConfig( + { + outputDir, + }, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: 'my-app', + root: 'root', + }, + }, + ); + expect(toNormalizedPath(resultingOutDir)).toEqual( + expect.stringContaining(toNormalizedPath('../dist/packages/test-folder')), + ); + }); + + it('should work with empty projectConfig', () => { + const { outputDir } = persistConfig( + {}, + { + workspaceRoot: '/test/root/workspace-root', + }, + ); + + expect(toNormalizedPath(outputDir)).toEqual( + expect.stringContaining(toNormalizedPath('.code-pushup')), + ); + }); + + it('should provide NO default persist filename', () => { + const projectName = 'my-app'; + expect( + persistConfig( + {}, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: projectName, + root: 'root', + }, + }, + ), + ).toEqual(expect.not.objectContaining({ filename: expect.anything() })); + }); + + it('should parse given persist filename', () => { + const projectName = 'my-app'; + expect( + persistConfig( + { + filename: 'my-name', + }, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: projectName, + root: 'root', + }, + }, + ), + ).toEqual(expect.objectContaining({ filename: 'my-name' })); + }); +}); + +describe('uploadConfig', () => { + const baseUploadConfig = { + server: 'https://base-portal.code.pushup.dev', + apiKey: 'apiKey', + organization: 'organization', + }; + + let processEnvSpy: MockInstance<[], NodeJS.ProcessEnv>; + beforeAll(() => { + processEnvSpy = vi.spyOn(process, 'env', 'get').mockReturnValue({}); + }); + afterAll(() => { + processEnvSpy.mockRestore(); + }); + + it('should provide default upload project options as project name', () => { + const projectName = 'my-app'; + expect( + uploadConfig(baseUploadConfig, { + workspaceRoot: 'workspace-root', + projectConfig: { + name: projectName, + root: 'root', + }, + }), + ).toEqual(expect.objectContaining({ project: projectName })); + }); + + it('should parse upload project options', () => { + const projectName = 'utils'; + expect( + uploadConfig( + { + ...baseUploadConfig, + project: 'cli-utils', + }, + { + workspaceRoot: 'workspace-root', + projectConfig: { + name: projectName, + root: 'root', + }, + }, + ), + ).toEqual(expect.objectContaining({ project: 'cli-utils' })); + }); + + it('should parse upload server options', () => { + expect( + uploadConfig( + { + ...baseUploadConfig, + server: 'https://new1-portal.code.pushup.dev', + }, + { + workspaceRoot: 'workspace-root', + projectConfig: { + name: 'utils', + root: 'root', + }, + }, + ), + ).toEqual( + expect.objectContaining({ + server: 'https://new1-portal.code.pushup.dev', + }), + ); + }); + + it('should parse upload organization options', () => { + expect( + uploadConfig( + { + ...baseUploadConfig, + organization: 'code-pushup-v2', + }, + { + workspaceRoot: 'workspace-root', + projectConfig: { + name: 'utils', + root: 'root', + }, + }, + ), + ).toEqual(expect.objectContaining({ organization: 'code-pushup-v2' })); + }); + + it('should parse upload apiKey options', () => { + expect( + uploadConfig( + { + ...baseUploadConfig, + apiKey: '123456789', + }, + { + workspaceRoot: 'workspace-root', + projectConfig: { + name: 'utils', + root: 'root', + }, + }, + ), + ).toEqual(expect.objectContaining({ apiKey: '123456789' })); + }); + + it('should parse process.env options', () => { + processEnvSpy.mockReturnValue(ENV); + expect( + uploadConfig( + {}, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: 'my-app', + root: 'root', + }, + }, + ), + ).toEqual( + expect.objectContaining({ + server: ENV.CP_SERVER, + apiKey: ENV.CP_API_KEY, + organization: ENV.CP_ORGANIZATION, + project: ENV.CP_PROJECT, + timeout: Number(ENV.CP_TIMEOUT), + }), + ); + }); + + it('should options overwrite process.env vars', () => { + expect( + uploadConfig( + { + project: 'my-app2', + }, + { + workspaceRoot: 'workspaceRoot', + projectConfig: { + name: 'my-app', + root: 'root', + }, + }, + ), + ).toEqual(expect.objectContaining({ project: 'my-app2' })); + }); +}); diff --git a/packages/nx-plugin/src/executors/internal/context.ts b/packages/nx-plugin/src/executors/internal/context.ts new file mode 100644 index 000000000..d247311d8 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/context.ts @@ -0,0 +1,21 @@ +import { ExecutorContext } from 'nx/src/config/misc-interfaces'; +import { BaseNormalizedExecutorContext } from './types'; + +export type NormalizedExecutorContext = BaseNormalizedExecutorContext & { + projectName: string; +}; + +export function normalizeContext( + context: ExecutorContext, +): NormalizedExecutorContext { + const { + projectName = '', + root: workspaceRoot, + projectsConfigurations, + } = context; + return { + projectName, + projectConfig: projectsConfigurations?.projects[projectName], + workspaceRoot, + }; +} diff --git a/packages/nx-plugin/src/executors/internal/context.unit.test.ts b/packages/nx-plugin/src/executors/internal/context.unit.test.ts new file mode 100644 index 000000000..04acb3db7 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/context.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeContext } from './context'; + +describe('normalizeContext', () => { + it('should normalizeContext', () => { + const normalizedContext = normalizeContext({ + root: './', + projectName: 'my-app', + cwd: 'string', + projectsConfigurations: { + projects: { + ['my-app']: { + root: './my-app', + }, + }, + version: 0, + }, + isVerbose: false, + }); + expect(normalizedContext).toEqual({ + projectName: 'my-app', + projectConfig: { + root: './my-app', + }, + workspaceRoot: './', + }); + }); +}); diff --git a/packages/nx-plugin/src/executors/internal/env.ts b/packages/nx-plugin/src/executors/internal/env.ts new file mode 100644 index 000000000..bdc2c3e0f --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/env.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import type { UploadConfig } from '@code-pushup/models'; + +// load upload configuration from environment +const envSchema = z + .object({ + CP_SERVER: z.string().url().optional(), + CP_API_KEY: z.string().min(1).optional(), + CP_ORGANIZATION: z.string().min(1).optional(), + CP_PROJECT: z.string().min(1).optional(), + CP_TIMEOUT: z.string().regex(/^\d+$/).optional(), + }) + .partial(); + +type UploadEnvVars = z.infer; + +export function parseEnv(env: unknown = {}): Partial { + const upload: UploadEnvVars = envSchema.parse(env); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Object.fromEntries( + Object.entries(upload).map(([envKey, value]) => { + switch (envKey) { + case 'CP_SERVER': + return ['server', value]; + case 'CP_API_KEY': + return ['apiKey', value]; + case 'CP_ORGANIZATION': + return ['organization', value]; + case 'CP_PROJECT': + return ['project', value]; + case 'CP_TIMEOUT': + return Number(value) >= 0 ? ['timeout', Number(value)] : []; + default: + return []; + } + }), + ); +} diff --git a/packages/nx-plugin/src/executors/internal/env.unit.test.ts b/packages/nx-plugin/src/executors/internal/env.unit.test.ts new file mode 100644 index 000000000..eca10b8c5 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/env.unit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect } from 'vitest'; +import { parseEnv } from './env'; + +describe('parseEnv', () => { + it('should parse empty env vars', () => { + expect(parseEnv({})).toEqual({}); + }); + + it('should parse process.env.CP_SERVER option', () => { + expect(parseEnv({ CP_SERVER: 'https://portal.code.pushup.dev' })).toEqual( + expect.objectContaining({ server: 'https://portal.code.pushup.dev' }), + ); + }); + + it('should parse process.env.CP_ORGANIZATION option', () => { + expect(parseEnv({ CP_ORGANIZATION: 'code-pushup' })).toEqual( + expect.objectContaining({ organization: 'code-pushup' }), + ); + }); + + it('should parse process.env.CP_PROJECT option', () => { + expect(parseEnv({ CP_PROJECT: 'cli-utils' })).toEqual( + expect.objectContaining({ project: 'cli-utils' }), + ); + }); + + it('should parse process.env.CP_TIMEOUT option', () => { + expect(parseEnv({ CP_TIMEOUT: '3' })).toEqual( + expect.objectContaining({ timeout: 3 }), + ); + }); + + it('should throw for process.env.CP_TIMEOUT option < 0', () => { + expect(() => parseEnv({ CP_TIMEOUT: '-1' })).toThrow('Invalid'); + }); + + it('should throw for invalid URL in process.env.CP_SERVER option', () => { + expect(() => parseEnv({ CP_SERVER: 'httptpt' })).toThrow('Invalid url'); + }); +}); diff --git a/packages/nx-plugin/src/executors/internal/types.ts b/packages/nx-plugin/src/executors/internal/types.ts new file mode 100644 index 000000000..6202b4ba2 --- /dev/null +++ b/packages/nx-plugin/src/executors/internal/types.ts @@ -0,0 +1,41 @@ +import { ProjectConfiguration } from 'nx/src/config/workspace-json-project-json'; + +/** + * Types used in the executor only + */ +export type GeneralExecutorOnlyOptions = { + dryRun?: boolean; +}; + +/** + * executor types that apply for a subset of exector's. + * In this case the project related options + * + */ +export type ProjectExecutorOnlyOptions = { + projectPrefix?: string; +}; + +/** + * CLI types that apply globally for all commands. + */ +export type GlobalExecutorOptions = { + verbose?: boolean; + progress?: boolean; + config?: string; +}; + +/** + * CLI types that apply for a subset of commands. + * In this case the collection of data (collect, autorun, history) + */ +export type CollectExecutorOnlyOptions = { + onlyPlugins?: string[]; +}; + +/** + * context that is normalized for all executor's + */ +export type BaseNormalizedExecutorContext = { + projectConfig?: ProjectConfiguration; +} & { workspaceRoot: string }; diff --git a/packages/nx-plugin/src/generators/configuration/README.md b/packages/nx-plugin/src/generators/configuration/README.md new file mode 100644 index 000000000..90b82a71d --- /dev/null +++ b/packages/nx-plugin/src/generators/configuration/README.md @@ -0,0 +1,26 @@ +# Configuration Generator + +#### @code-pushup/nx-plugin:configuration + +## Usage + +`nx generate configuration ...` + +By default, the Nx plugin will search for existing configuration files. If they are not present it creates a `code-pushup.config.ts` and adds a target to your `project.json` file. + +You can specify the collection explicitly as follows: + +`nx g @code-pushup/nx-plugin:configuration ...` + +Show what will be generated without writing to disk: + +`nx g configuration ... --dry-run` + +## Options + +| Name | type | description | +| ----------------- | -------------------------------- | -------------------------------------------------------- | +| **--project** | `string` (REQUIRED) | The name of the project. | +| **--targetName** | `string` (DEFAULT 'code-pushup') | The id used to identify a target in your project.json. | +| **--skipProject** | `boolean` (DEFAULT false) | Skip adding the target to `project.json`. | +| **--skipConfig** | `boolean` (DEFAULT false) | Skip adding the `code-pushup.config.ts` to project root. | diff --git a/packages/nx-plugin/src/generators/configuration/__snapshots__/generator.integration.test.ts.snap b/packages/nx-plugin/src/generators/configuration/__snapshots__/generator.integration.test.ts.snap index 623556aa9..b7f7ed335 100644 --- a/packages/nx-plugin/src/generators/configuration/__snapshots__/generator.integration.test.ts.snap +++ b/packages/nx-plugin/src/generators/configuration/__snapshots__/generator.integration.test.ts.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`configuration generator > should add code-pushup.config.ts to the project root 1`] = ` +exports[`generateCodePushupConfig > should add code-pushup.config.ts to the project root 1`] = ` "import type { CoreConfig } from '@code-pushup/models'; // see: https://github.com/code-pushup/cli/blob/main/packages/models/docs/models-reference.md#coreconfig const config: CoreConfig = { plugins: [ // ... - ], + ] }; export default config; diff --git a/packages/nx-plugin/src/generators/configuration/generator.integration.test.ts b/packages/nx-plugin/src/generators/configuration/generator.integration.test.ts index 3bd7c3ea4..66077f1f6 100644 --- a/packages/nx-plugin/src/generators/configuration/generator.integration.test.ts +++ b/packages/nx-plugin/src/generators/configuration/generator.integration.test.ts @@ -5,68 +5,193 @@ import { } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { configurationGenerator } from './generator'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + addTargetToProject, + configurationGenerator, + generateCodePushupConfig, +} from './generator'; -describe('configuration generator', () => { +describe('generateCodePushupConfig', () => { let tree: Tree; const testProjectName = 'test-app'; - beforeEach(() => { tree = createTreeWithEmptyWorkspace(); - addProjectConfiguration(tree, testProjectName, { - root: testProjectName, - projectType: 'library', - sourceRoot: `${testProjectName}/src`, - targets: {}, + root: 'test-app', }); }); - it('should add code-pushup.config.ts to the project root', async () => { - await configurationGenerator(tree, { project: testProjectName }); + it('should add code-pushup.config.ts to the project root', () => { + generateCodePushupConfig( + tree, + { + root: testProjectName, + projectType: 'library', + sourceRoot: `${testProjectName}/src`, + targets: {}, + }, + { + project: testProjectName, + }, + ); + + expect(tree.exists('test-app/code-pushup.config.ts')).toBe(true); + expect( + tree.read('test-app/code-pushup.config.ts')?.toString(), + ).toMatchSnapshot(); + }); + + it('should skip code-pushup.config.ts generation if config in ts, mjs or js format already exists', () => { + tree.write(join('code-pushup.config.js'), 'export default {}'); + + generateCodePushupConfig( + tree, + { + root: testProjectName, + projectType: 'library', + sourceRoot: `${testProjectName}/src`, + targets: {}, + }, + { + project: testProjectName, + }, + ); + + expect(tree.exists('code-pushup.config.ts')).toBe(false); + }); + + it('should skip code-pushup.config.ts generation if skipConfig is given', () => { + generateCodePushupConfig( + tree, + { + root: testProjectName, + projectType: 'library', + sourceRoot: `${testProjectName}/src`, + targets: {}, + }, + { + project: testProjectName, + skipConfig: true, + }, + ); + + expect(tree.exists('code-pushup.config.ts')).toBe(false); + }); +}); + +describe('addTargetToProject', () => { + let tree: Tree; + const testProjectName = 'test-app'; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'test-app', { + root: 'test-app', + }); + }); + afterEach(() => { + //reset tree + tree.delete(testProjectName); + }); + + it('should generate a project target', () => { + addTargetToProject( + tree, + { + root: testProjectName, + projectType: 'library', + sourceRoot: `${testProjectName}/src`, + targets: {}, + }, + { + project: testProjectName, + }, + ); const projectConfiguration = readProjectConfiguration( tree, testProjectName, ); - expect(tree.exists('test-app/code-pushup.config.ts')).toBe(true); expect(projectConfiguration.targets?.['code-pushup']).toEqual({ - executor: 'nx:run-commands', - options: { - command: `code-pushup autorun --no-progress --config=${join( - './', - projectConfiguration.root, - 'code-pushup.config.ts', - )}`, + executor: '@code-pushup/nx-plugin:autorun', + }); + }); + + it('should use targetName to generate a project target', () => { + addTargetToProject( + tree, + { + root: testProjectName, + projectType: 'library', + sourceRoot: `${testProjectName}/src`, + targets: {}, + }, + { + project: testProjectName, + targetName: 'cp', }, + ); + + const projectConfiguration = readProjectConfiguration( + tree, + testProjectName, + ); + + expect(projectConfiguration.targets?.['cp']).toEqual({ + executor: '@code-pushup/nx-plugin:autorun', }); - expect( - tree.read('test-app/code-pushup.config.ts')?.toString(), - ).toMatchSnapshot(); }); - it('should skip code-pushup.config.ts generation if config fin in ts, mjs or js format already exists', async () => { - tree.write(join('code-pushup.config.js'), 'export default {}'); - await configurationGenerator(tree, { project: testProjectName }); + it('should skip target creation if skipTarget is used', () => { + addTargetToProject( + tree, + { + root: testProjectName, + projectType: 'library', + sourceRoot: `${testProjectName}/src`, + targets: {}, + }, + { + project: testProjectName, + skipTarget: true, + }, + ); const projectConfiguration = readProjectConfiguration( tree, testProjectName, ); + expect(projectConfiguration.targets).toBeUndefined(); + }); +}); - expect(tree.exists('code-pushup.config.ts')).toBe(false); +describe('configurationGenerator', () => { + let tree: Tree; + const testProjectName = 'test-app'; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'test-app', { + root: 'test-app', + }); + }); + + afterEach(() => { + tree.delete(testProjectName); + }); + + it('should generate a project target and config file', async () => { + await configurationGenerator(tree, { + project: testProjectName, + }); + + const projectConfiguration = readProjectConfiguration( + tree, + testProjectName, + ); expect(projectConfiguration.targets?.['code-pushup']).toEqual({ - executor: 'nx:run-commands', - options: { - command: `code-pushup autorun --no-progress --config=${join( - './', - projectConfiguration.root, - 'code-pushup.config.ts', - )}`, - }, + executor: '@code-pushup/nx-plugin:autorun', }); }); }); diff --git a/packages/nx-plugin/src/generators/configuration/generator.ts b/packages/nx-plugin/src/generators/configuration/generator.ts index ed63b9244..51071b03e 100644 --- a/packages/nx-plugin/src/generators/configuration/generator.ts +++ b/packages/nx-plugin/src/generators/configuration/generator.ts @@ -2,53 +2,69 @@ import { Tree, formatFiles, generateFiles, - logger, readProjectConfiguration, updateProjectConfiguration, } from '@nx/devkit'; import { join } from 'node:path'; -import { ConfigurationGeneratorSchema } from './schema'; +import { ProjectConfiguration } from 'nx/src/config/workspace-json-project-json'; +import { DEFAULT_TARGET_NAME } from '../../internal/constants'; +import { ConfigurationGeneratorOptions } from './schema'; export async function configurationGenerator( tree: Tree, - options: ConfigurationGeneratorSchema, + options: ConfigurationGeneratorOptions, ) { const projectConfiguration = readProjectConfiguration(tree, options.project); - const { root, targets } = projectConfiguration; + generateCodePushupConfig(tree, projectConfiguration, options); - const supportedFormats = ['ts', 'mjs', 'js']; - const firstExistingFormat = supportedFormats.find(ext => - tree.exists(join(root, `code-pushup.config.${ext}`)), - ); - if (firstExistingFormat) { - logger.warn( - `NOTE: No config file created as code-pushup.config.${firstExistingFormat} file already exists.`, - ); + addTargetToProject(tree, projectConfiguration, options); + + await formatFiles(tree); +} + +export function addTargetToProject( + tree: Tree, + projectConfiguration: ProjectConfiguration, + options: ConfigurationGeneratorOptions, +) { + const { targets } = projectConfiguration; + const { targetName, project, skipTarget } = options; + + if (skipTarget) { return; } - generateFiles(tree, join(__dirname, 'files'), root, options); + const codePushupTargetConfig = { + executor: '@code-pushup/nx-plugin:autorun', + }; - // @TODO remove when implementing https://github.com/code-pushup/cli/issues/619 - updateProjectConfiguration(tree, options.project, { + updateProjectConfiguration(tree, project, { ...projectConfiguration, targets: { ...targets, - 'code-pushup': { - executor: 'nx:run-commands', - options: { - command: `code-pushup autorun --no-progress --config=${join( - './', - root, - 'code-pushup.config.ts', - )}`, - }, - }, + [targetName ?? DEFAULT_TARGET_NAME]: codePushupTargetConfig, }, }); +} - await formatFiles(tree); +export function generateCodePushupConfig( + tree: Tree, + projectConfiguration: ProjectConfiguration, + options: ConfigurationGeneratorOptions, +) { + const { root } = projectConfiguration; + const supportedFormats = ['ts', 'mjs', 'js']; + const firstExistingFormat = supportedFormats.find(ext => + tree.exists(join(root, `code-pushup.config.${ext}`)), + ); + if (firstExistingFormat) { + console.warn( + `NOTE: No config file created as code-pushup.config.${firstExistingFormat} file already exists.`, + ); + } else { + generateFiles(tree, join(__dirname, 'files'), root, options); + } } export default configurationGenerator; diff --git a/packages/nx-plugin/src/generators/configuration/schema.d.ts b/packages/nx-plugin/src/generators/configuration/schema.d.ts index 11ec1f871..ad297d2d2 100644 --- a/packages/nx-plugin/src/generators/configuration/schema.d.ts +++ b/packages/nx-plugin/src/generators/configuration/schema.d.ts @@ -1,3 +1,6 @@ -export type ConfigurationGeneratorSchema = { +export type ConfigurationGeneratorOptions = { project: string; + targetName?: string; + skipTarget?: boolean; + skipConfig?: boolean; }; diff --git a/packages/nx-plugin/src/generators/configuration/schema.json b/packages/nx-plugin/src/generators/configuration/schema.json index 07f934f43..8f5f6b974 100644 --- a/packages/nx-plugin/src/generators/configuration/schema.json +++ b/packages/nx-plugin/src/generators/configuration/schema.json @@ -1,7 +1,8 @@ { "$schema": "http://json-schema.org/schema", - "$id": "ConfigurationGenerator", - "title": "", + "$id": "AddConfigurationToProject", + "title": "Add CodePushup configuration to a project", + "description": "Add CodePushup configuration to a project", "type": "object", "properties": { "project": { @@ -13,6 +14,23 @@ "$source": "argv", "index": 0 } + }, + "targetName": { + "type": "string", + "description": "The name of the target.", + "x-prompt": "Which name should the target get? default is code-pushup.", + "x-dropdown": "targetName", + "default": "code-pushup" + }, + "skipTarget": { + "type": "boolean", + "description": "Skip adding the target to project.json.", + "$default": "false" + }, + "skipConfig": { + "type": "boolean", + "description": "Skip adding the code-pushup.config.ts to the project root.", + "$default": "false" } }, "required": ["project"] diff --git a/packages/nx-plugin/src/generators/init/README.md b/packages/nx-plugin/src/generators/init/README.md new file mode 100644 index 000000000..53e36abf2 --- /dev/null +++ b/packages/nx-plugin/src/generators/init/README.md @@ -0,0 +1,23 @@ +# Init Generator + +#### @code-pushup/nx-plugin:init + +## Usage + +`nx generate configuration ...` + +By default, the Nx plugin will update your `package.json` with needed dependencies and register the plugin in your `nx.json` configuration. + +You can specify the collection explicitly as follows: + +`nx g @code-pushup/nx-plugin:init` + +Show what will be generated without writing to disk: + +`nx g @code-pushup/nx-plugin:init --dry-run` + +## Options + +| Name | type | description | +| --------------------- | --------------------------- | ---------------------------------------- | +| **--skipPackageJson** | `boolean` (DEFAULT `false`) | Skip adding `package.json` dependencies. | diff --git a/packages/nx-plugin/src/generators/init/generator.ts b/packages/nx-plugin/src/generators/init/generator.ts index 82c81f63d..d61c3dac2 100644 --- a/packages/nx-plugin/src/generators/init/generator.ts +++ b/packages/nx-plugin/src/generators/init/generator.ts @@ -16,7 +16,7 @@ import { cpModelVersion, cpNxPluginVersion, cpUtilsVersion, -} from '../versions'; +} from '../../internal/versions'; import { InitGeneratorSchema } from './schema'; const nxPluginPackageName = '@code-pushup/nx-plugin'; @@ -29,10 +29,10 @@ function checkDependenciesInstalled(host: Tree) { packageJson.devDependencies = packageJson.devDependencies ?? {}; // base deps - devDependencies[nxPluginPackageName] = cpNxPluginVersion(); - devDependencies['@code-pushup/models'] = cpModelVersion(); - devDependencies['@code-pushup/utils'] = cpUtilsVersion(); - devDependencies['@code-pushup/cli'] = cpCliVersion(); + devDependencies[nxPluginPackageName] = cpNxPluginVersion; + devDependencies['@code-pushup/models'] = cpModelVersion; + devDependencies['@code-pushup/utils'] = cpUtilsVersion; + devDependencies['@code-pushup/cli'] = cpCliVersion; return addDependenciesToPackageJson(host, dependencies, devDependencies); } diff --git a/packages/nx-plugin/src/generators/versions.ts b/packages/nx-plugin/src/generators/versions.ts deleted file mode 100644 index e624533e3..000000000 --- a/packages/nx-plugin/src/generators/versions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readJsonFile } from '@nx/devkit'; -import { join } from 'node:path'; -import type { PackageJson } from 'nx/src/utils/package-json'; - -const workspaceRoot = join(__dirname, '../../'); -const projectsFolder = join(__dirname, '../../../'); - -export const cpNxPluginVersion = () => loadPackageJson(workspaceRoot).version; -export const cpModelVersion = () => - loadPackageJson(join(projectsFolder, 'cli')).version; -export const cpUtilsVersion = () => - loadPackageJson(join(projectsFolder, 'utils')).version; -export const cpCliVersion = () => - loadPackageJson(join(projectsFolder, 'models')).version; - -function loadPackageJson(folderPath: string) { - return readJsonFile(join(folderPath, 'package.json')); -} diff --git a/packages/nx-plugin/src/internal/constants.ts b/packages/nx-plugin/src/internal/constants.ts new file mode 100644 index 000000000..cbab5c261 --- /dev/null +++ b/packages/nx-plugin/src/internal/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_TARGET_NAME = 'code-pushup'; diff --git a/packages/nx-plugin/src/internal/versions.ts b/packages/nx-plugin/src/internal/versions.ts new file mode 100644 index 000000000..fa4003b33 --- /dev/null +++ b/packages/nx-plugin/src/internal/versions.ts @@ -0,0 +1,21 @@ +import { readJsonFile } from '@nx/devkit'; +import { join } from 'node:path'; +import type { PackageJson } from 'nx/src/utils/package-json'; + +const workspaceRoot = join(__dirname, '../../'); +const projectsFolder = join(__dirname, '../../../'); + +export const cpNxPluginVersion = loadPackageJson(workspaceRoot).version; +export const cpModelVersion = loadPackageJson( + join(projectsFolder, 'cli'), +).version; +export const cpUtilsVersion = loadPackageJson( + join(projectsFolder, 'utils'), +).version; +export const cpCliVersion = loadPackageJson( + join(projectsFolder, 'models'), +).version; + +function loadPackageJson(folderPath: string): PackageJson { + return readJsonFile(join(folderPath, 'package.json')); +} diff --git a/packages/utils/src/lib/reports/log-stdout-summary.integration.test.ts b/packages/utils/src/lib/reports/log-stdout-summary.integration.test.ts index 934747604..c9251a056 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.integration.test.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.integration.test.ts @@ -1,11 +1,16 @@ import { beforeAll, describe, expect, vi } from 'vitest'; -import { removeColorCodes, reportMock } from '@code-pushup/test-utils'; +import { reportMock } from '@code-pushup/test-utils'; import { ui } from '../logging'; import { logStdoutSummary } from './log-stdout-summary'; import { scoreReport } from './scoring'; import { sortReport } from './sorting'; describe('logStdoutSummary', () => { + // removes all color codes from the output for snapshot readability + const removeColorCodes = (stdout: string) => + // eslint-disable-next-line no-control-regex + stdout.replace(/\u001B\[\d+m/g, ''); + let logs: string[]; beforeAll(() => { diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index ae88c8c93..2d42945e1 100644 --- a/testing/test-utils/src/index.ts +++ b/testing/test-utils/src/index.ts @@ -5,6 +5,7 @@ export * from './lib/utils/logging'; export * from './lib/utils/env'; export * from './lib/utils/git'; export * from './lib/utils/string'; +export * from './lib/utils/path'; // static mocks export * from './lib/utils/commit.mock'; diff --git a/testing/test-utils/src/lib/utils/path.ts b/testing/test-utils/src/lib/utils/path.ts new file mode 100644 index 000000000..0f6763116 --- /dev/null +++ b/testing/test-utils/src/lib/utils/path.ts @@ -0,0 +1,3 @@ +export function toNormalizedPath(path: string): string { + return path.replace(/\\/g, '/'); +}