From cbe02f239eafb2e99356c73f539c46038be8e5fe Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 17:54:54 +0200 Subject: [PATCH 01/48] feat(nx-plugin): add support for local code execution --- nx.json | 11 ++++++++++ .../nx-plugin/src/executors/cli/README.md | 3 ++- .../nx-plugin/src/executors/cli/executor.ts | 3 ++- .../nx-plugin/src/executors/cli/schema.json | 7 +++++++ .../nx-plugin/src/executors/internal/types.ts | 1 + .../nx-plugin/src/internal/execute-process.ts | 5 +++-- packages/nx-plugin/src/internal/types.ts | 4 +++- .../src/plugin/target/configuration-target.ts | 6 +++--- .../src/plugin/target/executor-target.ts | 21 ++++++++++++------- .../target/executor.target.unit.test.ts | 2 +- .../nx-plugin/src/plugin/target/targets.ts | 13 +++++++++--- 11 files changed, 57 insertions(+), 19 deletions(-) diff --git a/nx.json b/nx.json index b612c5eed..31c4bf016 100644 --- a/nx.json +++ b/nx.json @@ -344,6 +344,17 @@ "releaseTagPattern": "v{version}" }, "plugins": [ + { + "plugin": "@code-pushup/nx-plugin", + "options": { + "pluginBin": "packages/nx-plugin/dist", + "cliBin": "packages/cli/src/index.ts", + "env": { + "NODE_OPTIONS": "--import tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" + } + } + }, { "plugin": "@push-based/nx-verdaccio", "options": { diff --git a/packages/nx-plugin/src/executors/cli/README.md b/packages/nx-plugin/src/executors/cli/README.md index fdb01c9f7..2872ace3e 100644 --- a/packages/nx-plugin/src/executors/cli/README.md +++ b/packages/nx-plugin/src/executors/cli/README.md @@ -72,6 +72,7 @@ Show what will be executed without actually executing it: | ----------------- | --------- | ------------------------------------------------------------------ | | **projectPrefix** | `string` | prefix for upload.project on non root projects | | **dryRun** | `boolean` | To debug the executor, dry run the command without real execution. | -| **bin** | `string` | Path to Code PushUp CLI | +| **cliBin** | `string` | Path to Code PushUp CLI | +| **pluginBin** | `string` | Path to Code PushUp Nx Plugin | For all other options see the [CLI autorun documentation](../../../../cli/README.md#autorun-command). diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 7eece1129..035993233 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -19,7 +19,7 @@ export default async function runAutorunExecutor( context: ExecutorContext, ): Promise { const normalizedContext = normalizeContext(context); - const mergedOptions = mergeExecutorOptions( + const { env, ...mergedOptions } = mergeExecutorOptions( context.target?.options, terminalAndExecutorOptions, ); @@ -43,6 +43,7 @@ export default async function runAutorunExecutor( await executeProcess({ ...createCliCommandObject({ command, args: cliArgumentObject }), ...(context.cwd ? { cwd: context.cwd } : {}), + env, }); } catch (error) { logger.error(error); diff --git a/packages/nx-plugin/src/executors/cli/schema.json b/packages/nx-plugin/src/executors/cli/schema.json index 85cd0de19..983dd069f 100644 --- a/packages/nx-plugin/src/executors/cli/schema.json +++ b/packages/nx-plugin/src/executors/cli/schema.json @@ -21,6 +21,13 @@ "type": "string", "description": "Path to Code PushUp CLI" }, + "env": { + "type": "object", + "description": "Environment variables to set when running the command", + "additionalProperties": { + "type": "string" + } + }, "verbose": { "type": "boolean", "description": "Print additional logs" diff --git a/packages/nx-plugin/src/executors/internal/types.ts b/packages/nx-plugin/src/executors/internal/types.ts index 2f529038a..677d472ce 100644 --- a/packages/nx-plugin/src/executors/internal/types.ts +++ b/packages/nx-plugin/src/executors/internal/types.ts @@ -30,6 +30,7 @@ export type Command = export type GlobalExecutorOptions = { command?: Command; bin?: string; + env?: Record; verbose?: boolean; progress?: boolean; config?: string; diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index cf61f3e84..d9516e513 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -86,6 +86,7 @@ export type ProcessConfig = { command: string; args?: string[]; cwd?: string; + env?: Record; observer?: ProcessObserver; ignoreExitCode?: boolean; }; @@ -138,7 +139,7 @@ export type ProcessObserver = { * @param cfg - see {@link ProcessConfig} */ export function executeProcess(cfg: ProcessConfig): Promise { - const { observer, cwd, command, args, ignoreExitCode = false } = cfg; + const { observer, cwd, command, args, ignoreExitCode = false, env } = cfg; const { onStdout, onError, onComplete } = observer ?? {}; const date = new Date().toISOString(); const start = performance.now(); @@ -152,7 +153,7 @@ export function executeProcess(cfg: ProcessConfig): Promise { return new Promise((resolve, reject) => { // shell:true tells Windows to use shell command for spawning a child process - const process = spawn(command, args, { cwd, shell: true }); + const process = spawn(command, args, { cwd, shell: true, env }); // eslint-disable-next-line functional/no-let let stdout = ''; // eslint-disable-next-line functional/no-let diff --git a/packages/nx-plugin/src/internal/types.ts b/packages/nx-plugin/src/internal/types.ts index bf3a2d047..780c98ac4 100644 --- a/packages/nx-plugin/src/internal/types.ts +++ b/packages/nx-plugin/src/internal/types.ts @@ -1,4 +1,6 @@ export type DynamicTargetOptions = { targetName?: string; - bin?: string; + pluginBin?: string; + cliBin?: string; + env?: Record; }; diff --git a/packages/nx-plugin/src/plugin/target/configuration-target.ts b/packages/nx-plugin/src/plugin/target/configuration-target.ts index d19b9325b..5bf894b73 100644 --- a/packages/nx-plugin/src/plugin/target/configuration-target.ts +++ b/packages/nx-plugin/src/plugin/target/configuration-target.ts @@ -7,15 +7,15 @@ import { CP_TARGET_NAME } from '../constants.js'; export function createConfigurationTarget(options?: { targetName?: string; projectName?: string; - bin?: string; + pluginBin?: string; }): TargetConfiguration { const { projectName, - bin = PACKAGE_NAME, + pluginBin = PACKAGE_NAME, targetName = CP_TARGET_NAME, } = options ?? {}; return { - command: `nx g ${bin}:configuration ${objectToCliArgs({ + command: `nx g ${pluginBin}:configuration ${objectToCliArgs({ skipTarget: true, targetName, ...(projectName ? { project: projectName } : {}), diff --git a/packages/nx-plugin/src/plugin/target/executor-target.ts b/packages/nx-plugin/src/plugin/target/executor-target.ts index e8b52eb8f..b8ce67542 100644 --- a/packages/nx-plugin/src/plugin/target/executor-target.ts +++ b/packages/nx-plugin/src/plugin/target/executor-target.ts @@ -1,18 +1,25 @@ import type { TargetConfiguration } from '@nx/devkit'; +import type { AutorunCommandExecutorOptions } from '../../executors/cli/schema.js'; import { PACKAGE_NAME } from '../../internal/constants.js'; -import type { ProjectPrefixOptions } from '../types.js'; +import type { CreateNodesOptions } from '../types.js'; -export function createExecutorTarget(options?: { - bin?: string; - projectPrefix?: string; -}): TargetConfiguration { - const { bin = PACKAGE_NAME, projectPrefix } = options ?? {}; +export function createExecutorTarget( + options?: CreateNodesOptions, +): TargetConfiguration { + const { + pluginBin = PACKAGE_NAME, + projectPrefix, + cliBin, + env, + } = options ?? {}; return { - executor: `${bin}:cli`, + executor: `${pluginBin}:cli`, ...(projectPrefix ? { options: { projectPrefix, + ...(cliBin ? { bin: cliBin } : {}), + ...(env ? { env } : {}), }, } : {}), diff --git a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts index 610b44bd7..a70ed7507 100644 --- a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts +++ b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts @@ -9,7 +9,7 @@ describe('createExecutorTarget', () => { }); it('should use bin if provides', () => { - expect(createExecutorTarget({ bin: 'xyz' })).toStrictEqual({ + expect(createExecutorTarget({ pluginBin: 'xyz' })).toStrictEqual({ executor: 'xyz:cli', }); }); diff --git a/packages/nx-plugin/src/plugin/target/targets.ts b/packages/nx-plugin/src/plugin/target/targets.ts index eb68740ef..c006bab31 100644 --- a/packages/nx-plugin/src/plugin/target/targets.ts +++ b/packages/nx-plugin/src/plugin/target/targets.ts @@ -17,20 +17,27 @@ export type CreateTargetsOptions = { export async function createTargets(normalizedContext: CreateTargetsOptions) { const { targetName = CP_TARGET_NAME, - bin, + pluginBin, projectPrefix, + env, + cliBin, } = normalizedContext.createOptions; const rootFiles = await readdir(normalizedContext.projectRoot); return rootFiles.some(filename => filename.match(CODE_PUSHUP_CONFIG_REGEX)) ? { - [targetName]: createExecutorTarget({ bin, projectPrefix }), + [targetName]: createExecutorTarget({ + pluginBin: pluginBin, + projectPrefix, + env, + cliBin, + }), } : // if NO code-pushup.config.*.(ts|js|mjs) is present return configuration target { [`${targetName}--configuration`]: createConfigurationTarget({ targetName, projectName: normalizedContext.projectJson.name, - bin, + pluginBin: pluginBin, }), }; } From b556bd8a76e686d7da0afc5ed3275cc9ce417340 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 17:56:06 +0200 Subject: [PATCH 02/48] refactor: revert plugin options --- nx.json | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/nx.json b/nx.json index 31c4bf016..b612c5eed 100644 --- a/nx.json +++ b/nx.json @@ -344,17 +344,6 @@ "releaseTagPattern": "v{version}" }, "plugins": [ - { - "plugin": "@code-pushup/nx-plugin", - "options": { - "pluginBin": "packages/nx-plugin/dist", - "cliBin": "packages/cli/src/index.ts", - "env": { - "NODE_OPTIONS": "--import tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json" - } - } - }, { "plugin": "@push-based/nx-verdaccio", "options": { From 4bba9cd8452bd5ea592aae53ead0a1e45730418d Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 18:19:19 +0200 Subject: [PATCH 03/48] test: update e2e tests --- .../tests/plugin-create-nodes.e2e.test.ts | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index 7a69a281d..e5570efe6 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -103,12 +103,13 @@ describe('nx-plugin', () => { }); }); - it('should consider plugin option bin in configuration target', async () => { - const cwd = path.join(testFileDir, 'configuration-option-bin'); + it('should consider plugin option pluginBin in configuration target', async () => { + const cwd = path.join(testFileDir, 'configuration-option-pluginBin'); + const pluginBinPath = `packages/nx-plugin/dist`; registerPluginInWorkspace(tree, { plugin: '@code-pushup/nx-plugin', options: { - bin: 'XYZ', + pluginBin: pluginBinPath, }, }); await materializeTree(tree, cwd); @@ -120,7 +121,7 @@ describe('nx-plugin', () => { expect(projectJson.targets).toStrictEqual({ 'code-pushup--configuration': expect.objectContaining({ options: { - command: `nx g XYZ:configuration --skipTarget --targetName="code-pushup" --project="${project}"`, + command: `nx g ${pluginBinPath}:configuration --skipTarget --targetName="code-pushup" --project="${project}"`, }, }), }); @@ -205,12 +206,13 @@ describe('nx-plugin', () => { ); }); - it('should consider plugin option bin in executor target', async () => { - const cwd = path.join(testFileDir, 'configuration-option-bin'); + it('should consider plugin option pluginBin in executor target', async () => { + const cwd = path.join(testFileDir, 'executor-option-pluginBin'); + const pluginBinPath = `packages/nx-plugin/dist`; registerPluginInWorkspace(tree, { plugin: '@code-pushup/nx-plugin', options: { - bin: 'XYZ', + pluginBin: pluginBinPath, }, }); const { root } = readProjectConfiguration(tree, project); @@ -223,13 +225,39 @@ describe('nx-plugin', () => { expect(projectJson.targets).toStrictEqual({ 'code-pushup': expect.objectContaining({ - executor: 'XYZ:cli', + executor: `${pluginBinPath}:cli`, + }), + }); + }); + + it('should consider plugin option cliBin in executor target', async () => { + const cwd = path.join(testFileDir, 'executor-option-cliBin'); + const cliBinPath = `packages/cli/dist`; + registerPluginInWorkspace(tree, { + plugin: '@code-pushup/nx-plugin', + options: { + cliBin: cliBinPath, + }, + }); + const { root } = readProjectConfiguration(tree, project); + generateCodePushupConfig(tree, root); + await materializeTree(tree, cwd); + + const { code, projectJson } = await nxShowProjectJson(cwd, project); + + expect(code).toBe(0); + + expect(projectJson.targets).toStrictEqual({ + 'code-pushup': expect.objectContaining({ + options: { + bin: cliBinPath, + }, }), }); }); it('should consider plugin option projectPrefix in executor target', async () => { - const cwd = path.join(testFileDir, 'configuration-option-bin'); + const cwd = path.join(testFileDir, 'executor-option-projectPrefix'); registerPluginInWorkspace(tree, { plugin: '@code-pushup/nx-plugin', options: { From 47bc389f61f0d314dc3e92ab4c606ef9a055d6af Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 18:59:54 +0200 Subject: [PATCH 04/48] refactor: adjust target options --- packages/nx-plugin/src/plugin/target/executor-target.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nx-plugin/src/plugin/target/executor-target.ts b/packages/nx-plugin/src/plugin/target/executor-target.ts index b8ce67542..05492467f 100644 --- a/packages/nx-plugin/src/plugin/target/executor-target.ts +++ b/packages/nx-plugin/src/plugin/target/executor-target.ts @@ -14,11 +14,11 @@ export function createExecutorTarget( } = options ?? {}; return { executor: `${pluginBin}:cli`, - ...(projectPrefix + ...(cliBin || projectPrefix || env ? { options: { - projectPrefix, ...(cliBin ? { bin: cliBin } : {}), + ...(projectPrefix ? { projectPrefix } : {}), ...(env ? { env } : {}), }, } From 695acbc66cb28bb4f39699c7a782fdbd2322ed09 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 19:27:51 +0200 Subject: [PATCH 05/48] refactor: add int test for env vars --- .../src/executors/cli/executor.int.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/nx-plugin/src/executors/cli/executor.int.test.ts b/packages/nx-plugin/src/executors/cli/executor.int.test.ts index 740486f33..d959ede4e 100644 --- a/packages/nx-plugin/src/executors/cli/executor.int.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.int.test.ts @@ -53,4 +53,28 @@ describe('runAutorunExecutor', () => { }, }); }); + + it('should execute command with provided env vars', async () => { + const output = await runAutorunExecutor( + { + verbose: true, + env: { + NODE_OPTIONS: '--import tsx', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, + }, + executorContext('utils'), + ); + expect(output.success).toBe(true); + + expect(executeProcessSpy).toHaveBeenCalledTimes(1); + expect(executeProcessSpy).toHaveBeenCalledWith( + expect.objectContaining({ + env: { + NODE_OPTIONS: '--import tsx', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, + }), + ); + }); }); From 5d4fd60964324cd95196058b7871778fc2f961f3 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 20:11:39 +0200 Subject: [PATCH 06/48] refactor: remove dependencies on packages, use latest --- packages/nx-plugin/src/internal/versions.ts | 29 +++------------------ 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/packages/nx-plugin/src/internal/versions.ts b/packages/nx-plugin/src/internal/versions.ts index b7e24f64a..7fb570401 100644 --- a/packages/nx-plugin/src/internal/versions.ts +++ b/packages/nx-plugin/src/internal/versions.ts @@ -1,25 +1,4 @@ -import { readJsonFile } from '@nx/devkit'; -import * as path from 'node:path'; -import type { PackageJson } from 'nx/src/utils/package-json'; - -const workspaceRoot = path.join(__dirname, '../../'); -const projectsFolder = path.join(__dirname, '../../../'); - -export const cpNxPluginVersion = loadPackageJson(workspaceRoot).version; -export const cpModelVersion = loadPackageJson( - path.join(projectsFolder, 'cli'), -).version; -export const cpUtilsVersion = loadPackageJson( - path.join(projectsFolder, 'utils'), -).version; -export const cpCliVersion = loadPackageJson( - path.join(projectsFolder, 'models'), -).version; - -/** - * Load the package.json file from the given folder path. - * @param folderPath - */ -function loadPackageJson(folderPath: string): PackageJson { - return readJsonFile(path.join(folderPath, 'package.json')); -} +export const cpNxPluginVersion = 'latest'; +export const cpModelVersion = 'latest'; +export const cpUtilsVersion = 'latest'; +export const cpCliVersion = 'latest'; From 052e18c4db996d661a9f0e3cee3cbf7e06b697e9 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 21:57:53 +0200 Subject: [PATCH 07/48] refactor: fix missing bin in command --- .../src/executors/cli/executor.int.test.ts | 19 +++++++++++++++++++ .../nx-plugin/src/executors/cli/executor.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.int.test.ts b/packages/nx-plugin/src/executors/cli/executor.int.test.ts index d959ede4e..f8beeb24a 100644 --- a/packages/nx-plugin/src/executors/cli/executor.int.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.int.test.ts @@ -54,6 +54,25 @@ describe('runAutorunExecutor', () => { }); }); + it('should execute command with provided bin', async () => { + const bin = 'packages/cli/dist'; + const output = await runAutorunExecutor( + { + verbose: true, + bin, + }, + executorContext('utils'), + ); + expect(output.success).toBe(true); + + expect(executeProcessSpy).toHaveBeenCalledTimes(1); + expect(executeProcessSpy).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([bin]), + }), + ); + }); + it('should execute command with provided env vars', async () => { const output = await runAutorunExecutor( { diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 035993233..33e95a15f 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -19,7 +19,7 @@ export default async function runAutorunExecutor( context: ExecutorContext, ): Promise { const normalizedContext = normalizeContext(context); - const { env, ...mergedOptions } = mergeExecutorOptions( + const { env, bin, ...mergedOptions } = mergeExecutorOptions( context.target?.options, terminalAndExecutorOptions, ); @@ -30,6 +30,7 @@ export default async function runAutorunExecutor( const { dryRun, verbose, command } = mergedOptions; const commandString = createCliCommandString({ command, + bin, args: cliArgumentObject, }); if (verbose) { @@ -41,7 +42,7 @@ export default async function runAutorunExecutor( } else { try { await executeProcess({ - ...createCliCommandObject({ command, args: cliArgumentObject }), + ...createCliCommandObject({ command, args: cliArgumentObject, bin }), ...(context.cwd ? { cwd: context.cwd } : {}), env, }); From e6ac40dbea381ae31bf335ca0325c62927131310 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 22:24:29 +0200 Subject: [PATCH 08/48] refactor: add colored formatting --- .../nx-plugin/src/executors/cli/executor.ts | 23 ++++++++++++++----- .../nx-plugin/src/executors/internal/cli.ts | 21 +++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 33e95a15f..dd2053983 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -3,6 +3,8 @@ import { executeProcess } from '../../internal/execute-process.js'; import { createCliCommandObject, createCliCommandString, + formatCommandLog, + objectToCliArgs, } from '../internal/cli.js'; import { normalizeContext } from '../internal/context.js'; import type { AutorunCommandExecutorOptions } from './schema.js'; @@ -19,10 +21,11 @@ export default async function runAutorunExecutor( context: ExecutorContext, ): Promise { const normalizedContext = normalizeContext(context); - const { env, bin, ...mergedOptions } = mergeExecutorOptions( - context.target?.options, - terminalAndExecutorOptions, - ); + const { + env, + bin = '@code-pushup/cli', + ...mergedOptions + } = mergeExecutorOptions(context.target?.options, terminalAndExecutorOptions); const cliArgumentObject = parseAutorunExecutorOptions( mergedOptions, normalizedContext, @@ -33,12 +36,20 @@ export default async function runAutorunExecutor( bin, args: cliArgumentObject, }); + const coloredCommandString = formatCommandLog( + 'npx', + [ + bin, + ...objectToCliArgs({ _: command ? [command] : [], ...cliArgumentObject }), + ], + env, + ); if (verbose) { logger.info(`Run CLI executor ${command ?? ''}`); - logger.info(`Command: ${commandString}`); + logger.info(`Command: ${coloredCommandString}`); } if (dryRun) { - logger.warn(`DryRun execution of: ${commandString}`); + logger.warn(`DryRun execution of: ${coloredCommandString}`); } else { try { await executeProcess({ diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index bab74a8f1..8d2d0d70a 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -1,4 +1,5 @@ import { logger } from '@nx/devkit'; +import ansis from 'ansis'; import type { ProcessConfig } from '../../internal/execute-process.js'; export function createCliCommandString(options?: { @@ -12,6 +13,26 @@ export function createCliCommandString(options?: { )}`; } +export function formatCommandLog( + command: string, + args: string[] = [], + env?: Record, +): string { + const logElements: string[] = []; + if (env) { + const envVars = Object.entries(env).map( + ([key, value]) => + `${ansis.green(key)}="${ansis.blueBright(value.replaceAll('"', ''))}"`, + ); + logElements.push(...envVars); + } + logElements.push(ansis.cyan(command)); + if (args.length > 0) { + logElements.push(ansis.white(args.join(' '))); + } + return logElements.join(' '); +} + export function createCliCommandObject(options?: { args?: Record; command?: string; From 311679f487086445f65f82e5244820196d86bbde Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 22:36:45 +0200 Subject: [PATCH 09/48] refactor: fix tests for colored formatting --- .../src/executors/cli/executor.unit.test.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts index daab1594c..26b21efe5 100644 --- a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts @@ -1,7 +1,7 @@ import { logger } from '@nx/devkit'; import { afterAll, afterEach, beforeEach, expect, vi } from 'vitest'; import { executorContext } from '@code-pushup/test-nx-utils'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { MEMFS_VOLUME, removeColorCodes } from '@code-pushup/test-utils'; import * as executeProcessModule from '../../internal/execute-process.js'; import runAutorunExecutor from './executor.js'; @@ -128,9 +128,14 @@ describe('runAutorunExecutor', () => { expect(loggerInfoSpy).toHaveBeenCalledWith( expect.stringContaining(`Run CLI executor`), ); - expect(loggerInfoSpy).toHaveBeenCalledWith( - expect.stringContaining('Command: npx @code-pushup/cli'), + const logs = loggerInfoSpy.mock.calls.map((call: any) => + removeColorCodes(call[0]), ); + expect( + logs.some((log: string) => + log.includes('Command: npx @code-pushup/cli --verbose'), + ), + ).toBe(true); }); it('should log command if dryRun is set', async () => { @@ -138,10 +143,13 @@ describe('runAutorunExecutor', () => { expect(loggerInfoSpy).toHaveBeenCalledTimes(0); expect(loggerWarnSpy).toHaveBeenCalledTimes(1); - expect(loggerWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'DryRun execution of: npx @code-pushup/cli --dryRun', - ), + const logs = loggerWarnSpy.mock.calls.map((call: any) => + removeColorCodes(call[0]), ); + expect( + logs.some((log: string) => + log.includes('DryRun execution of: npx @code-pushup/cli --dryRun'), + ), + ).toBe(true); }); }); From 8f3b7fac27569e9ccb92d8d4cadf2f7651278a61 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 22:41:15 +0200 Subject: [PATCH 10/48] chore: cache nxv-e2e-setup --- nx.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nx.json b/nx.json index b612c5eed..3804d2f10 100644 --- a/nx.json +++ b/nx.json @@ -126,6 +126,9 @@ "inputs": ["default", "test-vitest-inputs"], "dependsOn": ["^build"] }, + "nxv-env-setup": { + "cache": true + }, "nxv-pkg-install": { "parallelism": false }, From e9e4a3a3dedc0d47652c1ac71fc98499693f9d5a Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 23:07:54 +0200 Subject: [PATCH 11/48] refactor: wip --- packages/nx-plugin/src/executors/internal/cli.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index 8d2d0d70a..a942803b0 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -1,5 +1,5 @@ import { logger } from '@nx/devkit'; -import ansis from 'ansis'; +import chalk from 'chalk'; import type { ProcessConfig } from '../../internal/execute-process.js'; export function createCliCommandString(options?: { @@ -22,13 +22,16 @@ export function formatCommandLog( if (env) { const envVars = Object.entries(env).map( ([key, value]) => - `${ansis.green(key)}="${ansis.blueBright(value.replaceAll('"', ''))}"`, + `${chalk.green(key)}="${chalk.blueBright(value.replaceAll('"', ''))}"`, ); + // eslint-disable-next-line functional/immutable-data logElements.push(...envVars); } - logElements.push(ansis.cyan(command)); + // eslint-disable-next-line functional/immutable-data + logElements.push(chalk.cyan(command)); if (args.length > 0) { - logElements.push(ansis.white(args.join(' '))); + // eslint-disable-next-line functional/immutable-data + logElements.push(chalk.white(args.join(' '))); } return logElements.join(' '); } From a1d0f9d082676d22f64da3885fba319b5d3ee4d0 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 23:18:03 +0200 Subject: [PATCH 12/48] refactor: wip 2 --- packages/nx-plugin/src/executors/internal/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index a942803b0..a6d37ef95 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -31,7 +31,7 @@ export function formatCommandLog( logElements.push(chalk.cyan(command)); if (args.length > 0) { // eslint-disable-next-line functional/immutable-data - logElements.push(chalk.white(args.join(' '))); + logElements.push(chalk.dim.gray(args.join(' '))); } return logElements.join(' '); } From f1dd383730f357214863e322c0cadb915b222dfc Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 5 Sep 2025 23:36:45 +0200 Subject: [PATCH 13/48] refactor: wip 3 --- .../nx-plugin/src/executors/cli/executor.ts | 43 ++++++++----------- .../nx-plugin/src/internal/execute-process.ts | 29 +++++++++++-- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index dd2053983..2f5cd1456 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -36,36 +36,27 @@ export default async function runAutorunExecutor( bin, args: cliArgumentObject, }); - const coloredCommandString = formatCommandLog( - 'npx', - [ - bin, - ...objectToCliArgs({ _: command ? [command] : [], ...cliArgumentObject }), - ], - env, - ); + if (verbose) { logger.info(`Run CLI executor ${command ?? ''}`); - logger.info(`Command: ${coloredCommandString}`); } - if (dryRun) { - logger.warn(`DryRun execution of: ${coloredCommandString}`); - } else { - try { - await executeProcess({ - ...createCliCommandObject({ command, args: cliArgumentObject, bin }), - ...(context.cwd ? { cwd: context.cwd } : {}), - env, - }); - } catch (error) { - logger.error(error); - return { - success: false, - command: commandString, - error: error as Error, - }; - } + + try { + await executeProcess({ + ...createCliCommandObject({ command, args: cliArgumentObject, bin }), + ...(context.cwd ? { cwd: context.cwd } : {}), + env, + dryRun, + }); + } catch (error) { + logger.error(error); + return { + success: false, + command: commandString, + error: error as Error, + }; } + return { success: true, command: commandString, diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index d9516e513..b8e95db4b 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -1,6 +1,7 @@ import { gray } from 'ansis'; import { spawn } from 'node:child_process'; import { ui } from '@code-pushup/utils'; +import { formatCommandLog } from '../executors/internal/cli.js'; export function calcDuration(start: number, stop?: number): number { return Math.round((stop ?? performance.now()) - start); @@ -88,6 +89,7 @@ export type ProcessConfig = { cwd?: string; env?: Record; observer?: ProcessObserver; + dryRun?: boolean; ignoreExitCode?: boolean; }; @@ -139,18 +141,39 @@ export type ProcessObserver = { * @param cfg - see {@link ProcessConfig} */ export function executeProcess(cfg: ProcessConfig): Promise { - const { observer, cwd, command, args, ignoreExitCode = false, env } = cfg; + const { + observer, + cwd, + command, + args, + ignoreExitCode = false, + env, + dryRun, + } = cfg; const { onStdout, onError, onComplete } = observer ?? {}; const date = new Date().toISOString(); const start = performance.now(); - const logCommand = [command, ...(args || [])].join(' '); ui().logger.log( gray( - `Executing command:\n${logCommand}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, + `Executing command:\n${formatCommandLog( + 'npx', + [command, ...(args ?? [])], + env, + )}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, ), ); + if (dryRun) { + return Promise.resolve({ + code: 0, + stdout: '@code-pushup executed in dry run mode', + stderr: '', + date, + duration: calcDuration(start), + }); + } + return new Promise((resolve, reject) => { // shell:true tells Windows to use shell command for spawning a child process const process = spawn(command, args, { cwd, shell: true, env }); From 93d1d406068809d9e894b66fefb92e36a504f721 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 00:00:03 +0200 Subject: [PATCH 14/48] refactor: fix e2e --- e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index e5570efe6..afefc52d2 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -192,15 +192,14 @@ describe('nx-plugin', () => { const { stdout, stderr } = await executeProcess({ command: 'npx', - args: ['nx', 'run', `${project}:code-pushup`, '--dryRun'], + args: ['nx', 'run', `${project}:code-pushup`, '--dryRun', '--verbose'], cwd, }); - const cleanStderr = removeColorCodes(stderr); - // @TODO create test environment for working plugin. This here misses package-lock.json to execute correctly - expect(cleanStderr).toContain('DryRun execution of: npx @code-pushup/cli'); - const cleanStdout = removeColorCodes(stdout); + // @TODO create test environment for working plugin. This here misses package-lock.json to execute correctly + expect(cleanStdout).toContain('Executing command:'); + expect(cleanStdout).toContain('npx @code-pushup/cli'); expect(cleanStdout).toContain( 'NX Successfully ran target code-pushup for project my-lib', ); From 41c900b9a4e015f5b498c8601aa2ba353f3ce6cf Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 00:02:38 +0200 Subject: [PATCH 15/48] refactor: fix unit test --- .../src/executors/cli/executor.unit.test.ts | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts index 26b21efe5..00150a71c 100644 --- a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts @@ -124,32 +124,25 @@ describe('runAutorunExecutor', () => { expect(output.command).toMatch('--verbose'); expect(loggerWarnSpy).toHaveBeenCalledTimes(0); - expect(loggerInfoSpy).toHaveBeenCalledTimes(2); + expect(loggerInfoSpy).toHaveBeenCalledTimes(1); expect(loggerInfoSpy).toHaveBeenCalledWith( expect.stringContaining(`Run CLI executor`), ); - const logs = loggerInfoSpy.mock.calls.map((call: any) => - removeColorCodes(call[0]), - ); - expect( - logs.some((log: string) => - log.includes('Command: npx @code-pushup/cli --verbose'), - ), - ).toBe(true); }); - it('should log command if dryRun is set', async () => { - await runAutorunExecutor({ dryRun: true }, executorContext('utils')); + it('should call executeProcess with dryRun option', async () => { + const output = await runAutorunExecutor( + { dryRun: true }, + executorContext('utils'), + ); - expect(loggerInfoSpy).toHaveBeenCalledTimes(0); - expect(loggerWarnSpy).toHaveBeenCalledTimes(1); - const logs = loggerWarnSpy.mock.calls.map((call: any) => - removeColorCodes(call[0]), + expect(output.success).toBe(true); + expect(executeProcessSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dryRun: true, + }), ); - expect( - logs.some((log: string) => - log.includes('DryRun execution of: npx @code-pushup/cli --dryRun'), - ), - ).toBe(true); + expect(loggerInfoSpy).toHaveBeenCalledTimes(0); + expect(loggerWarnSpy).toHaveBeenCalledTimes(0); }); }); From 60183db32d5e40b2fca67f768487d291770cb5e1 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 00:02:48 +0200 Subject: [PATCH 16/48] refactor: fix lint --- packages/nx-plugin/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nx-plugin/package.json b/packages/nx-plugin/package.json index f5b779192..0d962f65e 100644 --- a/packages/nx-plugin/package.json +++ b/packages/nx-plugin/package.json @@ -37,7 +37,10 @@ "@nx/devkit": ">=17.0.0", "ansis": "^3.3.0", "nx": ">=17.0.0", - "zod": "^4.0.5" + "zod": "^4.0.5", + "chalk": "5.3.0", + "vite": "6.3.5", + "tsconfig-paths": "^4.2.0" }, "files": [ "src", From ea73523c5900eea0dfff9ea791b7849b68329736 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 00:11:22 +0200 Subject: [PATCH 17/48] refactor: fix lint --- e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index afefc52d2..84bc2a5d7 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -190,7 +190,7 @@ describe('nx-plugin', () => { await materializeTree(tree, cwd); - const { stdout, stderr } = await executeProcess({ + const { stdout } = await executeProcess({ command: 'npx', args: ['nx', 'run', `${project}:code-pushup`, '--dryRun', '--verbose'], cwd, From 454960b2b90169349938131340a4920ff256fd43 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 01:48:00 +0200 Subject: [PATCH 18/48] refactor: fix lint --- packages/nx-plugin/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/nx-plugin/package.json b/packages/nx-plugin/package.json index 0d962f65e..74ff721ac 100644 --- a/packages/nx-plugin/package.json +++ b/packages/nx-plugin/package.json @@ -38,9 +38,7 @@ "ansis": "^3.3.0", "nx": ">=17.0.0", "zod": "^4.0.5", - "chalk": "5.3.0", - "vite": "6.3.5", - "tsconfig-paths": "^4.2.0" + "chalk": "5.3.0" }, "files": [ "src", From b368ac09aa7e2c0594b85c1c50aed82614f633c7 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 13:23:37 +0200 Subject: [PATCH 19/48] refactor: fix lint --- .../nx-plugin/src/executors/cli/executor.ts | 9 ++++----- .../nx-plugin/src/executors/internal/cli.ts | 20 +++++++++++-------- .../nx-plugin/src/internal/execute-process.ts | 8 ++++---- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 2f5cd1456..0b8f6e587 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -21,11 +21,10 @@ export default async function runAutorunExecutor( context: ExecutorContext, ): Promise { const normalizedContext = normalizeContext(context); - const { - env, - bin = '@code-pushup/cli', - ...mergedOptions - } = mergeExecutorOptions(context.target?.options, terminalAndExecutorOptions); + const { env, bin, ...mergedOptions } = mergeExecutorOptions( + context.target?.options, + terminalAndExecutorOptions, + ); const cliArgumentObject = parseAutorunExecutorOptions( mergedOptions, normalizedContext, diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index a6d37ef95..95fcd728f 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -13,11 +13,15 @@ export function createCliCommandString(options?: { )}`; } -export function formatCommandLog( - command: string, - args: string[] = [], - env?: Record, -): string { +export function formatCommandLog({ + command, + args = [], + env, +}: { + command: string; + args: string[]; + env?: Record; +}): string { const logElements: string[] = []; if (env) { const envVars = Object.entries(env).map( @@ -41,10 +45,10 @@ export function createCliCommandObject(options?: { command?: string; bin?: string; }): ProcessConfig { - const { bin = '@code-pushup/cli', command, args } = options ?? {}; + const { bin = 'npx @code-pushup/cli', command, args } = options ?? {}; return { - command: 'npx', - args: [bin, ...objectToCliArgs({ _: command ?? [], ...args })], + command: bin, + args: [...objectToCliArgs({ _: command ?? [], ...args })], observer: { onError: error => { logger.error(error.message); diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index b8e95db4b..c3c2f875a 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -156,11 +156,11 @@ export function executeProcess(cfg: ProcessConfig): Promise { ui().logger.log( gray( - `Executing command:\n${formatCommandLog( - 'npx', - [command, ...(args ?? [])], + `Executing command:\n${formatCommandLog({ + command, + args: args ?? [], env, - )}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, + })}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, ), ); From d5e05a4be8aa3233939015f1c9a07539990525f0 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 13:40:56 +0200 Subject: [PATCH 20/48] refactor: adjust command logic --- packages/nx-plugin/src/executors/internal/cli.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index 95fcd728f..d51380a88 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -42,13 +42,14 @@ export function formatCommandLog({ export function createCliCommandObject(options?: { args?: Record; - command?: string; + command?: 'autorun' | 'collect' | 'upload' | 'print-config' | string; bin?: string; }): ProcessConfig { const { bin = 'npx @code-pushup/cli', command, args } = options ?? {}; + const finalCommand = bin.split(' ').at(0) as string; return { - command: bin, - args: [...objectToCliArgs({ _: command ?? [], ...args })], + command: finalCommand, + args: [bin.slice(1), ...objectToCliArgs({ _: command ?? [], ...args })], observer: { onError: error => { logger.error(error.message); From 204109323ab315494b60f7fd6af2caee91f63f0f Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 13:46:20 +0200 Subject: [PATCH 21/48] refactor: adjust command logic --- packages/nx-plugin/src/executors/internal/cli.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index d51380a88..ec2e49b98 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -46,10 +46,14 @@ export function createCliCommandObject(options?: { bin?: string; }): ProcessConfig { const { bin = 'npx @code-pushup/cli', command, args } = options ?? {}; - const finalCommand = bin.split(' ').at(0) as string; + const binArr = bin.split(' '); + const finalCommand = binArr.at(0) as string; return { command: finalCommand, - args: [bin.slice(1), ...objectToCliArgs({ _: command ?? [], ...args })], + args: [ + ...binArr.slice(1), + ...objectToCliArgs({ _: command ?? [], ...args }), + ], observer: { onError: error => { logger.error(error.message); From bebae7c68cc8ca7dcf2c123f0b875fb1f45940a4 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 14:18:05 +0200 Subject: [PATCH 22/48] refactor: wip --- .../nx-plugin/src/internal/execute-process.ts | 77 ++++++++++++++++++- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index c3c2f875a..81fcfda93 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -7,6 +7,64 @@ export function calcDuration(start: number, stop?: number): number { return Math.round((stop ?? performance.now()) - start); } +/** + * Processes Node.js specific environment variables that can't be passed via NODE_OPTIONS. + * Extracts flags like --import from NODE_OPTIONS and adds them directly to the command arguments. + */ +function processNodeOptions({ + command, + args, + env, +}: { + command: string; + args: string[]; + env?: Record; +}): { + processedCommand: string; + processedArgs: string[]; + processedEnv?: Record; +} { + if (!env || command !== 'node') { + return { + processedCommand: command, + processedArgs: args, + processedEnv: env, + }; + } + + const processedEnv = { ...env }; + const processedArgs = [...args]; + + // Handle NODE_OPTIONS that contain flags not allowed in environment variables + if (processedEnv.NODE_OPTIONS) { + const nodeOptions = processedEnv.NODE_OPTIONS; + + // Extract --import flag which is not allowed in NODE_OPTIONS + const importMatch = nodeOptions.match(/--import[=\s]+([^\s]+)/); + if (importMatch) { + // Add --import flag directly to node arguments + processedArgs.unshift(`--import=${importMatch[1]}`); + + // Remove --import from NODE_OPTIONS + processedEnv.NODE_OPTIONS = nodeOptions + .replace(/--import[=\s]+[^\s]+/, '') + .trim(); + + // If NODE_OPTIONS is now empty, remove it entirely + if (!processedEnv.NODE_OPTIONS) { + delete processedEnv.NODE_OPTIONS; + } + } + } + + return { + processedCommand: command, + processedArgs: processedArgs, + processedEnv: + Object.keys(processedEnv).length > 0 ? processedEnv : undefined, + }; +} + /** * Represents the process result. * @category Types @@ -154,12 +212,19 @@ export function executeProcess(cfg: ProcessConfig): Promise { const date = new Date().toISOString(); const start = performance.now(); + // Handle Node.js specific environment variables that can't be passed via NODE_OPTIONS + const { processedCommand, processedArgs, processedEnv } = processNodeOptions({ + command, + args: args ?? [], + env, + }); + ui().logger.log( gray( `Executing command:\n${formatCommandLog({ - command, - args: args ?? [], - env, + command: processedCommand, + args: processedArgs, + env: processedEnv, })}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, ), ); @@ -176,7 +241,11 @@ export function executeProcess(cfg: ProcessConfig): Promise { return new Promise((resolve, reject) => { // shell:true tells Windows to use shell command for spawning a child process - const process = spawn(command, args, { cwd, shell: true, env }); + const process = spawn(processedCommand, processedArgs, { + cwd, + shell: true, + env: processedEnv, + }); // eslint-disable-next-line functional/no-let let stdout = ''; // eslint-disable-next-line functional/no-let From f947823a3c1e0c868ea47cb598700194314c258c Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 23:33:56 +0200 Subject: [PATCH 23/48] refactor: wip --- CONTRIBUTING.md | 50 ++++++++ .../tests/plugin-create-nodes.e2e.test.ts | 48 -------- nx.json | 33 ++---- package-lock.json | 51 +++++++++ package.json | 45 ++++---- .../nx-plugin/src/executors/cli/README.md | 1 - .../nx-plugin/src/executors/internal/cli.ts | 12 +- .../nx-plugin/src/internal/execute-process.ts | 107 ++++++------------ .../src/internal/execute-process.unit.test.ts | 2 +- packages/nx-plugin/src/internal/types.ts | 1 - .../src/plugin/target/configuration-target.ts | 9 +- .../src/plugin/target/executor-target.ts | 9 +- .../target/executor.target.unit.test.ts | 8 +- .../nx-plugin/src/plugin/target/targets.ts | 3 - project.json | 53 ++++++--- 15 files changed, 218 insertions(+), 214 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8c8e23a5..abe381f57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,6 +69,56 @@ You can control the execution of long-running tests over the `INCLUDE_SLOW_TESTS To change this setup, open (or create) the `.env` file in the root folder. Edit or add the environment variable there as follows: `INCLUDE_SLOW_TESTS=true`. +### Executing local code + +_Execute the latest CLI source_ + +```jsonc +// project.json +{ + "targets": { + "exec-local-cli-source": { + "executor": "nx:run-commands", + "options": { + "command": "node packages/cli/src/index.ts", + "args": ["--no-progress", "--verbose", "--help"], + } + }, + "exec-local-cli-source-and-local-plugin-source": { + "executor": "nx:run-commands", + "options": { + "command": "node packages/cli/src/index.ts", + "args": ["--no-progress", "--verbose", "--onlyPlugins=js-packages"], + "env": { + "NODE_OPTIONS": "--import tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" + } + } + } + } +``` + +_Setup code-pushup targets with the nx plugin_ + +```jsonc +// nx.json +{ + "plugins": [ + { + "plugin": "@code-pushup/nx-plugin", + "options": { + "cliBin": "node ./packages/cli/src/index.ts", + "env": { + // nx.json + "NODE_OPTIONS": "--import=tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json", + }, + }, + }, + ], +} +``` + ## Git Commit messages must follow [conventional commits](https://conventionalcommits.org/) format. diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index 84bc2a5d7..2508c2eeb 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -103,30 +103,6 @@ describe('nx-plugin', () => { }); }); - it('should consider plugin option pluginBin in configuration target', async () => { - const cwd = path.join(testFileDir, 'configuration-option-pluginBin'); - const pluginBinPath = `packages/nx-plugin/dist`; - registerPluginInWorkspace(tree, { - plugin: '@code-pushup/nx-plugin', - options: { - pluginBin: pluginBinPath, - }, - }); - await materializeTree(tree, cwd); - - const { code, projectJson } = await nxShowProjectJson(cwd, project); - - expect(code).toBe(0); - - expect(projectJson.targets).toStrictEqual({ - 'code-pushup--configuration': expect.objectContaining({ - options: { - command: `nx g ${pluginBinPath}:configuration --skipTarget --targetName="code-pushup" --project="${project}"`, - }, - }), - }); - }); - it('should NOT add config targets dynamically if the project is configured', async () => { const cwd = path.join(testFileDir, 'configuration-already-configured'); registerPluginInWorkspace(tree, '@code-pushup/nx-plugin'); @@ -205,30 +181,6 @@ describe('nx-plugin', () => { ); }); - it('should consider plugin option pluginBin in executor target', async () => { - const cwd = path.join(testFileDir, 'executor-option-pluginBin'); - const pluginBinPath = `packages/nx-plugin/dist`; - registerPluginInWorkspace(tree, { - plugin: '@code-pushup/nx-plugin', - options: { - pluginBin: pluginBinPath, - }, - }); - const { root } = readProjectConfiguration(tree, project); - generateCodePushupConfig(tree, root); - await materializeTree(tree, cwd); - - const { code, projectJson } = await nxShowProjectJson(cwd, project); - - expect(code).toBe(0); - - expect(projectJson.targets).toStrictEqual({ - 'code-pushup': expect.objectContaining({ - executor: `${pluginBinPath}:cli`, - }), - }); - }); - it('should consider plugin option cliBin in executor target', async () => { const cwd = path.join(testFileDir, 'executor-option-cliBin'); const cliBinPath = `packages/cli/dist`; diff --git a/nx.json b/nx.json index 3804d2f10..66dd7d3cd 100644 --- a/nx.json +++ b/nx.json @@ -140,29 +140,6 @@ "watch": false } }, - "code-pushup": { - "cache": false, - "outputs": [ - "{projectRoot}/.code-pushup/report.md", - "{projectRoot}/.code-pushup/report.json" - ], - "executor": "nx:run-commands", - "options": { - "command": "node packages/cli/src/index.ts", - "args": [ - "--no-progress", - "--verbose", - "--config={projectRoot}/code-pushup.config.ts", - "--cache.read", - "--persist.outputDir={projectRoot}/.code-pushup", - "--upload.project=cli-{projectName}" - ], - "env": { - "NODE_OPTIONS": "--import tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json" - } - } - }, "code-pushup-coverage": { "cache": true, "inputs": ["default", "code-pushup-inputs"], @@ -347,6 +324,16 @@ "releaseTagPattern": "v{version}" }, "plugins": [ + { + "plugin": "@code-pushup/nx-plugin", + "options": { + "cliBin": "node ./packages/cli/src/index.ts", + "env": { + "NODE_OPTIONS": "--import=tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" + } + } + }, { "plugin": "@push-based/nx-verdaccio", "options": { diff --git a/package-lock.json b/package-lock.json index 6b81834f4..b117f6822 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@actions/github": "^6.0.1", "@beaussan/nx-knip": "^0.0.5-15", "@code-pushup/eslint-config": "^0.14.2", + "@code-pushup/nx-plugin": "https://pkg.pr.new/code-pushup/cli/@code-pushup/nx-plugin@1109", "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@commitlint/config-nx-scopes": "^19.5.0", @@ -2401,6 +2402,33 @@ } } }, + "node_modules/@code-pushup/models": { + "version": "0.79.1", + "resolved": "https://pkg.pr.new/code-pushup/cli/@code-pushup/models@204109323ab315494b60f7fd6af2caee91f63f0f", + "integrity": "sha512-7BDiRwBjPsxJDH0BwG9+YC98zjfcJm4DepTqhvg5BoB83ZSr0jQBuulaw46IugYDlUvMSAXVRNAilp8O2sV/Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-material-icons": "^0.1.0", + "zod": "^4.0.5" + } + }, + "node_modules/@code-pushup/nx-plugin": { + "version": "0.79.1", + "resolved": "https://pkg.pr.new/code-pushup/cli/@code-pushup/nx-plugin@1109", + "integrity": "sha512-h3uq7gjfsylU23EQMDSvfgiW3D/Y3sJqETMG/rLz33P+JH2G1xjRCBFODqbxUT83rgyNyiSE+wnB9txC39NKsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@code-pushup/models": "https://pkg.pr.new/code-pushup/cli/@code-pushup/models@204109323ab315494b60f7fd6af2caee91f63f0f", + "@code-pushup/utils": "https://pkg.pr.new/code-pushup/cli/@code-pushup/utils@204109323ab315494b60f7fd6af2caee91f63f0f", + "@nx/devkit": ">=17.0.0", + "ansis": "^3.3.0", + "chalk": "5.3.0", + "nx": ">=17.0.0", + "zod": "^4.0.5" + } + }, "node_modules/@code-pushup/portal-client": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.16.0.tgz", @@ -2413,6 +2441,29 @@ "vscode-material-icons": "^0.1.0" } }, + "node_modules/@code-pushup/utils": { + "version": "0.79.1", + "resolved": "https://pkg.pr.new/code-pushup/cli/@code-pushup/utils@204109323ab315494b60f7fd6af2caee91f63f0f", + "integrity": "sha512-y0OKf14dJL3ogi90XLwYQkZe96AzCe8blpKY8bH+0ljE3/zsPrfbrM9MP3byxJMaRUTb9k+EnOXkd8EpPTSg/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@code-pushup/models": "https://pkg.pr.new/code-pushup/cli/@code-pushup/models@204109323ab315494b60f7fd6af2caee91f63f0f", + "@isaacs/cliui": "^8.0.2", + "@poppinss/cliui": "^6.4.0", + "ansis": "^3.3.0", + "build-md": "^0.4.2", + "bundle-require": "^5.1.0", + "esbuild": "^0.25.2", + "multi-progress-bars": "^5.0.3", + "semver": "^7.6.0", + "simple-git": "^3.20.0", + "zod": "^4.0.5" + }, + "engines": { + "node": ">=17.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 385e84c8c..1cb92172e 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,4 @@ { - "name": "@code-pushup/cli-workspace", - "version": "0.0.0", - "license": "MIT", - "description": "A CLI to run all kinds of code quality measurements to align your team with company goals", - "homepage": "https://code-pushup.dev", - "bugs": { - "url": "https://github.com/code-pushup/cli/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/code-pushup/cli.git" - }, - "type": "module", - "scripts": { - "prepare": "husky install", - "commit": "git-cz", - "knip": "knip" - }, - "private": true, - "engines": { - "node": ">=22.14" - }, "dependencies": { "@code-pushup/portal-client": "^0.16.0", "@isaacs/cliui": "^8.0.2", @@ -53,6 +31,7 @@ "@actions/github": "^6.0.1", "@beaussan/nx-knip": "^0.0.5-15", "@code-pushup/eslint-config": "^0.14.2", + "@code-pushup/nx-plugin": "https://pkg.pr.new/code-pushup/cli/@code-pushup/nx-plugin@1109", "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@commitlint/config-nx-scopes": "^19.5.0", @@ -125,6 +104,28 @@ "vitest": "1.3.1", "zod2md": "^0.2.4" }, + "name": "@code-pushup/cli-workspace", + "version": "0.0.0", + "license": "MIT", + "description": "A CLI to run all kinds of code quality measurements to align your team with company goals", + "homepage": "https://code-pushup.dev", + "bugs": { + "url": "https://github.com/code-pushup/cli/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/code-pushup/cli.git" + }, + "type": "module", + "scripts": { + "prepare": "husky install", + "commit": "git-cz", + "knip": "knip" + }, + "private": true, + "engines": { + "node": ">=22.14" + }, "optionalDependencies": { "@esbuild/darwin-arm64": "^0.25.9", "@nx/nx-darwin-arm64": "^21.4.1", diff --git a/packages/nx-plugin/src/executors/cli/README.md b/packages/nx-plugin/src/executors/cli/README.md index 2872ace3e..85d7a9747 100644 --- a/packages/nx-plugin/src/executors/cli/README.md +++ b/packages/nx-plugin/src/executors/cli/README.md @@ -73,6 +73,5 @@ Show what will be executed without actually executing it: | **projectPrefix** | `string` | prefix for upload.project on non root projects | | **dryRun** | `boolean` | To debug the executor, dry run the command without real execution. | | **cliBin** | `string` | Path to Code PushUp CLI | -| **pluginBin** | `string` | Path to Code PushUp Nx Plugin | For all other options see the [CLI autorun documentation](../../../../cli/README.md#autorun-command). diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index ec2e49b98..7b2597b1b 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -47,13 +47,15 @@ export function createCliCommandObject(options?: { }): ProcessConfig { const { bin = 'npx @code-pushup/cli', command, args } = options ?? {}; const binArr = bin.split(' '); - const finalCommand = binArr.at(0) as string; + + // If bin contains spaces, use the first part as command and rest as args + // If bin is a single path, default to 'npx' and use the bin as first arg + const finalCommand = binArr.length > 1 ? binArr[0] : 'npx'; + const binArgs = binArr.length > 1 ? binArr.slice(1) : [bin]; + return { command: finalCommand, - args: [ - ...binArr.slice(1), - ...objectToCliArgs({ _: command ?? [], ...args }), - ], + args: [...binArgs, ...objectToCliArgs({ _: command ?? [], ...args })], observer: { onError: error => { logger.error(error.message); diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index 81fcfda93..112cb1b5e 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -1,5 +1,5 @@ import { gray } from 'ansis'; -import { spawn } from 'node:child_process'; +import { exec } from 'node:child_process'; import { ui } from '@code-pushup/utils'; import { formatCommandLog } from '../executors/internal/cli.js'; @@ -7,62 +7,19 @@ export function calcDuration(start: number, stop?: number): number { return Math.round((stop ?? performance.now()) - start); } -/** - * Processes Node.js specific environment variables that can't be passed via NODE_OPTIONS. - * Extracts flags like --import from NODE_OPTIONS and adds them directly to the command arguments. - */ -function processNodeOptions({ - command, - args, - env, -}: { - command: string; - args: string[]; - env?: Record; -}): { - processedCommand: string; - processedArgs: string[]; - processedEnv?: Record; -} { - if (!env || command !== 'node') { - return { - processedCommand: command, - processedArgs: args, - processedEnv: env, - }; +function buildCommandString(command: string, args: string[] = []): string { + if (args.length === 0) { + return command; } - const processedEnv = { ...env }; - const processedArgs = [...args]; - - // Handle NODE_OPTIONS that contain flags not allowed in environment variables - if (processedEnv.NODE_OPTIONS) { - const nodeOptions = processedEnv.NODE_OPTIONS; - - // Extract --import flag which is not allowed in NODE_OPTIONS - const importMatch = nodeOptions.match(/--import[=\s]+([^\s]+)/); - if (importMatch) { - // Add --import flag directly to node arguments - processedArgs.unshift(`--import=${importMatch[1]}`); - - // Remove --import from NODE_OPTIONS - processedEnv.NODE_OPTIONS = nodeOptions - .replace(/--import[=\s]+[^\s]+/, '') - .trim(); - - // If NODE_OPTIONS is now empty, remove it entirely - if (!processedEnv.NODE_OPTIONS) { - delete processedEnv.NODE_OPTIONS; - } + const escapedArgs = args.map(arg => { + if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { + return `"${arg.replace(/"/g, '\\"')}"`; } - } + return arg; + }); - return { - processedCommand: command, - processedArgs: processedArgs, - processedEnv: - Object.keys(processedEnv).length > 0 ? processedEnv : undefined, - }; + return `${command} ${escapedArgs.join(' ')}`; } /** @@ -212,19 +169,15 @@ export function executeProcess(cfg: ProcessConfig): Promise { const date = new Date().toISOString(); const start = performance.now(); - // Handle Node.js specific environment variables that can't be passed via NODE_OPTIONS - const { processedCommand, processedArgs, processedEnv } = processNodeOptions({ - command, - args: args ?? [], - env, - }); + // Build the complete command string + const commandString = buildCommandString(command, args ?? []); ui().logger.log( gray( `Executing command:\n${formatCommandLog({ - command: processedCommand, - args: processedArgs, - env: processedEnv, + command, + args: args ?? [], + env, })}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, ), ); @@ -240,31 +193,35 @@ export function executeProcess(cfg: ProcessConfig): Promise { } return new Promise((resolve, reject) => { - // shell:true tells Windows to use shell command for spawning a child process - const process = spawn(processedCommand, processedArgs, { + const childProcess = exec(commandString, { cwd, - shell: true, - env: processedEnv, + env: env ? { ...process.env, ...env } : process.env, + maxBuffer: 1024 * 1000000, // 1GB buffer like nx:run-commands + windowsHide: false, }); // eslint-disable-next-line functional/no-let let stdout = ''; // eslint-disable-next-line functional/no-let let stderr = ''; - process.stdout.on('data', data => { - stdout += String(data); - onStdout?.(String(data)); - }); + if (childProcess.stdout) { + childProcess.stdout.on('data', data => { + stdout += String(data); + onStdout?.(String(data)); + }); + } - process.stderr.on('data', data => { - stderr += String(data); - }); + if (childProcess.stderr) { + childProcess.stderr.on('data', data => { + stderr += String(data); + }); + } - process.on('error', err => { + childProcess.on('error', err => { stderr += err.toString(); }); - process.on('close', code => { + childProcess.on('close', code => { const timings = { date, duration: calcDuration(start) }; if (code === 0 || ignoreExitCode) { onComplete?.(); diff --git a/packages/nx-plugin/src/internal/execute-process.unit.test.ts b/packages/nx-plugin/src/internal/execute-process.unit.test.ts index 5893b867f..a3a041d9b 100644 --- a/packages/nx-plugin/src/internal/execute-process.unit.test.ts +++ b/packages/nx-plugin/src/internal/execute-process.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getAsyncProcessRunnerConfig } from '@code-pushup/test-utils'; import { type ProcessObserver, executeProcess } from './execute-process.js'; diff --git a/packages/nx-plugin/src/internal/types.ts b/packages/nx-plugin/src/internal/types.ts index 780c98ac4..fed464dda 100644 --- a/packages/nx-plugin/src/internal/types.ts +++ b/packages/nx-plugin/src/internal/types.ts @@ -1,6 +1,5 @@ export type DynamicTargetOptions = { targetName?: string; - pluginBin?: string; cliBin?: string; env?: Record; }; diff --git a/packages/nx-plugin/src/plugin/target/configuration-target.ts b/packages/nx-plugin/src/plugin/target/configuration-target.ts index 5bf894b73..63c4fe57c 100644 --- a/packages/nx-plugin/src/plugin/target/configuration-target.ts +++ b/packages/nx-plugin/src/plugin/target/configuration-target.ts @@ -7,15 +7,10 @@ import { CP_TARGET_NAME } from '../constants.js'; export function createConfigurationTarget(options?: { targetName?: string; projectName?: string; - pluginBin?: string; }): TargetConfiguration { - const { - projectName, - pluginBin = PACKAGE_NAME, - targetName = CP_TARGET_NAME, - } = options ?? {}; + const { projectName, targetName = CP_TARGET_NAME } = options ?? {}; return { - command: `nx g ${pluginBin}:configuration ${objectToCliArgs({ + command: `nx g ${PACKAGE_NAME}:configuration ${objectToCliArgs({ skipTarget: true, targetName, ...(projectName ? { project: projectName } : {}), diff --git a/packages/nx-plugin/src/plugin/target/executor-target.ts b/packages/nx-plugin/src/plugin/target/executor-target.ts index 05492467f..77dda510f 100644 --- a/packages/nx-plugin/src/plugin/target/executor-target.ts +++ b/packages/nx-plugin/src/plugin/target/executor-target.ts @@ -6,14 +6,9 @@ import type { CreateNodesOptions } from '../types.js'; export function createExecutorTarget( options?: CreateNodesOptions, ): TargetConfiguration { - const { - pluginBin = PACKAGE_NAME, - projectPrefix, - cliBin, - env, - } = options ?? {}; + const { projectPrefix, cliBin, env } = options ?? {}; return { - executor: `${pluginBin}:cli`, + executor: `${PACKAGE_NAME}:cli`, ...(cliBin || projectPrefix || env ? { options: { diff --git a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts index a70ed7507..c76b95b18 100644 --- a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts +++ b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts @@ -2,18 +2,12 @@ import { expect } from 'vitest'; import { createExecutorTarget } from './executor-target.js'; describe('createExecutorTarget', () => { - it('should return executor target without project name', () => { + it('should return executor target with default package name', () => { expect(createExecutorTarget()).toStrictEqual({ executor: '@code-pushup/nx-plugin:cli', }); }); - it('should use bin if provides', () => { - expect(createExecutorTarget({ pluginBin: 'xyz' })).toStrictEqual({ - executor: 'xyz:cli', - }); - }); - it('should use projectPrefix if provided', () => { expect(createExecutorTarget({ projectPrefix: 'cli' })).toStrictEqual({ executor: '@code-pushup/nx-plugin:cli', diff --git a/packages/nx-plugin/src/plugin/target/targets.ts b/packages/nx-plugin/src/plugin/target/targets.ts index c006bab31..2d4662c60 100644 --- a/packages/nx-plugin/src/plugin/target/targets.ts +++ b/packages/nx-plugin/src/plugin/target/targets.ts @@ -17,7 +17,6 @@ export type CreateTargetsOptions = { export async function createTargets(normalizedContext: CreateTargetsOptions) { const { targetName = CP_TARGET_NAME, - pluginBin, projectPrefix, env, cliBin, @@ -26,7 +25,6 @@ export async function createTargets(normalizedContext: CreateTargetsOptions) { return rootFiles.some(filename => filename.match(CODE_PUSHUP_CONFIG_REGEX)) ? { [targetName]: createExecutorTarget({ - pluginBin: pluginBin, projectPrefix, env, cliBin, @@ -37,7 +35,6 @@ export async function createTargets(normalizedContext: CreateTargetsOptions) { [`${targetName}--configuration`]: createConfigurationTarget({ targetName, projectName: normalizedContext.projectJson.name, - pluginBin: pluginBin, }), }; } diff --git a/project.json b/project.json index c53ed1a80..ba026903d 100644 --- a/project.json +++ b/project.json @@ -2,6 +2,44 @@ "name": "cli-workspace", "$schema": "node_modules/nx/schemas/project-schema.json", "targets": { + "cp-local-cli": { + "executor": "nx:run-commands", + "options": { + "command": "node packages/cli/src/index.ts", + "args": ["--no-progress", "--verbose", "--onlyPlugins=js-packages"], + "env": { + "NODE_OPTIONS": "--import tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" + } + } + }, + "cp-local-executor-cpplugins": { + "dependsOn": ["nx-plugin:build"], + "executor": "./packages/nx-plugin/dist:cli", + "options": { + "verbose": true, + "progress": false, + "onlyPlugins": ["js-packages"], + "env": { + "NODE_OPTIONS": "--import tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" + } + } + }, + "cp-local-executor-cli-cpplugins": { + "dependsOn": ["nx-plugin:build"], + "executor": "./packages/nx-plugin/dist:cli", + "options": { + "bin": "node packages/cli/src/index.ts", + "verbose": true, + "progress": false, + "onlyPlugins": ["js-packages"], + "env": { + "NODE_OPTIONS": "--import tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" + } + } + }, "code-pushup-js-packages": {}, "code-pushup-lighthouse": {}, "code-pushup-coverage": { @@ -25,19 +63,6 @@ ] }, "code-pushup-jsdocs": {}, - "code-pushup-typescript": {}, - "code-pushup": { - "dependsOn": ["code-pushup-*"], - "executor": "nx:run-commands", - "options": { - "args": [ - "--no-progress", - "--verbose", - "--cache.read", - "--persist.outputDir={projectRoot}/.code-pushup", - "--upload.project={projectName}" - ] - } - } + "code-pushup-typescript": {} } } From 752a97e09f4a48412994e9871207d782096c5ffe Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 23:34:28 +0200 Subject: [PATCH 24/48] refactor: wip --- nx.json | 36 ++++++++++++++++++++------------ package-lock.json | 51 --------------------------------------------- package.json | 45 ++++++++++++++++++++-------------------- project.json | 53 +++++++++++++---------------------------------- 4 files changed, 59 insertions(+), 126 deletions(-) diff --git a/nx.json b/nx.json index 66dd7d3cd..b612c5eed 100644 --- a/nx.json +++ b/nx.json @@ -126,9 +126,6 @@ "inputs": ["default", "test-vitest-inputs"], "dependsOn": ["^build"] }, - "nxv-env-setup": { - "cache": true - }, "nxv-pkg-install": { "parallelism": false }, @@ -140,6 +137,29 @@ "watch": false } }, + "code-pushup": { + "cache": false, + "outputs": [ + "{projectRoot}/.code-pushup/report.md", + "{projectRoot}/.code-pushup/report.json" + ], + "executor": "nx:run-commands", + "options": { + "command": "node packages/cli/src/index.ts", + "args": [ + "--no-progress", + "--verbose", + "--config={projectRoot}/code-pushup.config.ts", + "--cache.read", + "--persist.outputDir={projectRoot}/.code-pushup", + "--upload.project=cli-{projectName}" + ], + "env": { + "NODE_OPTIONS": "--import tsx", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" + } + } + }, "code-pushup-coverage": { "cache": true, "inputs": ["default", "code-pushup-inputs"], @@ -324,16 +344,6 @@ "releaseTagPattern": "v{version}" }, "plugins": [ - { - "plugin": "@code-pushup/nx-plugin", - "options": { - "cliBin": "node ./packages/cli/src/index.ts", - "env": { - "NODE_OPTIONS": "--import=tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json" - } - } - }, { "plugin": "@push-based/nx-verdaccio", "options": { diff --git a/package-lock.json b/package-lock.json index b117f6822..6b81834f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "@actions/github": "^6.0.1", "@beaussan/nx-knip": "^0.0.5-15", "@code-pushup/eslint-config": "^0.14.2", - "@code-pushup/nx-plugin": "https://pkg.pr.new/code-pushup/cli/@code-pushup/nx-plugin@1109", "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@commitlint/config-nx-scopes": "^19.5.0", @@ -2402,33 +2401,6 @@ } } }, - "node_modules/@code-pushup/models": { - "version": "0.79.1", - "resolved": "https://pkg.pr.new/code-pushup/cli/@code-pushup/models@204109323ab315494b60f7fd6af2caee91f63f0f", - "integrity": "sha512-7BDiRwBjPsxJDH0BwG9+YC98zjfcJm4DepTqhvg5BoB83ZSr0jQBuulaw46IugYDlUvMSAXVRNAilp8O2sV/Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-material-icons": "^0.1.0", - "zod": "^4.0.5" - } - }, - "node_modules/@code-pushup/nx-plugin": { - "version": "0.79.1", - "resolved": "https://pkg.pr.new/code-pushup/cli/@code-pushup/nx-plugin@1109", - "integrity": "sha512-h3uq7gjfsylU23EQMDSvfgiW3D/Y3sJqETMG/rLz33P+JH2G1xjRCBFODqbxUT83rgyNyiSE+wnB9txC39NKsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@code-pushup/models": "https://pkg.pr.new/code-pushup/cli/@code-pushup/models@204109323ab315494b60f7fd6af2caee91f63f0f", - "@code-pushup/utils": "https://pkg.pr.new/code-pushup/cli/@code-pushup/utils@204109323ab315494b60f7fd6af2caee91f63f0f", - "@nx/devkit": ">=17.0.0", - "ansis": "^3.3.0", - "chalk": "5.3.0", - "nx": ">=17.0.0", - "zod": "^4.0.5" - } - }, "node_modules/@code-pushup/portal-client": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.16.0.tgz", @@ -2441,29 +2413,6 @@ "vscode-material-icons": "^0.1.0" } }, - "node_modules/@code-pushup/utils": { - "version": "0.79.1", - "resolved": "https://pkg.pr.new/code-pushup/cli/@code-pushup/utils@204109323ab315494b60f7fd6af2caee91f63f0f", - "integrity": "sha512-y0OKf14dJL3ogi90XLwYQkZe96AzCe8blpKY8bH+0ljE3/zsPrfbrM9MP3byxJMaRUTb9k+EnOXkd8EpPTSg/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@code-pushup/models": "https://pkg.pr.new/code-pushup/cli/@code-pushup/models@204109323ab315494b60f7fd6af2caee91f63f0f", - "@isaacs/cliui": "^8.0.2", - "@poppinss/cliui": "^6.4.0", - "ansis": "^3.3.0", - "build-md": "^0.4.2", - "bundle-require": "^5.1.0", - "esbuild": "^0.25.2", - "multi-progress-bars": "^5.0.3", - "semver": "^7.6.0", - "simple-git": "^3.20.0", - "zod": "^4.0.5" - }, - "engines": { - "node": ">=17.0.0" - } - }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 1cb92172e..385e84c8c 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,26 @@ { + "name": "@code-pushup/cli-workspace", + "version": "0.0.0", + "license": "MIT", + "description": "A CLI to run all kinds of code quality measurements to align your team with company goals", + "homepage": "https://code-pushup.dev", + "bugs": { + "url": "https://github.com/code-pushup/cli/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/code-pushup/cli.git" + }, + "type": "module", + "scripts": { + "prepare": "husky install", + "commit": "git-cz", + "knip": "knip" + }, + "private": true, + "engines": { + "node": ">=22.14" + }, "dependencies": { "@code-pushup/portal-client": "^0.16.0", "@isaacs/cliui": "^8.0.2", @@ -31,7 +53,6 @@ "@actions/github": "^6.0.1", "@beaussan/nx-knip": "^0.0.5-15", "@code-pushup/eslint-config": "^0.14.2", - "@code-pushup/nx-plugin": "https://pkg.pr.new/code-pushup/cli/@code-pushup/nx-plugin@1109", "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@commitlint/config-nx-scopes": "^19.5.0", @@ -104,28 +125,6 @@ "vitest": "1.3.1", "zod2md": "^0.2.4" }, - "name": "@code-pushup/cli-workspace", - "version": "0.0.0", - "license": "MIT", - "description": "A CLI to run all kinds of code quality measurements to align your team with company goals", - "homepage": "https://code-pushup.dev", - "bugs": { - "url": "https://github.com/code-pushup/cli/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/code-pushup/cli.git" - }, - "type": "module", - "scripts": { - "prepare": "husky install", - "commit": "git-cz", - "knip": "knip" - }, - "private": true, - "engines": { - "node": ">=22.14" - }, "optionalDependencies": { "@esbuild/darwin-arm64": "^0.25.9", "@nx/nx-darwin-arm64": "^21.4.1", diff --git a/project.json b/project.json index ba026903d..c53ed1a80 100644 --- a/project.json +++ b/project.json @@ -2,44 +2,6 @@ "name": "cli-workspace", "$schema": "node_modules/nx/schemas/project-schema.json", "targets": { - "cp-local-cli": { - "executor": "nx:run-commands", - "options": { - "command": "node packages/cli/src/index.ts", - "args": ["--no-progress", "--verbose", "--onlyPlugins=js-packages"], - "env": { - "NODE_OPTIONS": "--import tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json" - } - } - }, - "cp-local-executor-cpplugins": { - "dependsOn": ["nx-plugin:build"], - "executor": "./packages/nx-plugin/dist:cli", - "options": { - "verbose": true, - "progress": false, - "onlyPlugins": ["js-packages"], - "env": { - "NODE_OPTIONS": "--import tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json" - } - } - }, - "cp-local-executor-cli-cpplugins": { - "dependsOn": ["nx-plugin:build"], - "executor": "./packages/nx-plugin/dist:cli", - "options": { - "bin": "node packages/cli/src/index.ts", - "verbose": true, - "progress": false, - "onlyPlugins": ["js-packages"], - "env": { - "NODE_OPTIONS": "--import tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json" - } - } - }, "code-pushup-js-packages": {}, "code-pushup-lighthouse": {}, "code-pushup-coverage": { @@ -63,6 +25,19 @@ ] }, "code-pushup-jsdocs": {}, - "code-pushup-typescript": {} + "code-pushup-typescript": {}, + "code-pushup": { + "dependsOn": ["code-pushup-*"], + "executor": "nx:run-commands", + "options": { + "args": [ + "--no-progress", + "--verbose", + "--cache.read", + "--persist.outputDir={projectRoot}/.code-pushup", + "--upload.project={projectName}" + ] + } + } } } From 4e9051812d7f859425e864fb6b01b7b4b108eb4e Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sat, 6 Sep 2025 23:43:03 +0200 Subject: [PATCH 25/48] refactor: wip --- packages/nx-plugin/src/executors/internal/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index 7b2597b1b..b348df1b5 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -50,7 +50,7 @@ export function createCliCommandObject(options?: { // If bin contains spaces, use the first part as command and rest as args // If bin is a single path, default to 'npx' and use the bin as first arg - const finalCommand = binArr.length > 1 ? binArr[0] : 'npx'; + const finalCommand = binArr.length > 1 ? (binArr[0] ?? 'npx') : 'npx'; const binArgs = binArr.length > 1 ? binArr.slice(1) : [bin]; return { From a180d5cdf8b711a342d24b7b96f9f5da19f751ba Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 7 Sep 2025 00:52:43 +0200 Subject: [PATCH 26/48] test(nx-plugin): add tests for buildCommandString --- .../nx-plugin/src/internal/execute-process.ts | 10 ++-- .../src/internal/execute-process.unit.test.ts | 46 ++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index 112cb1b5e..461066adf 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -7,7 +7,10 @@ export function calcDuration(start: number, stop?: number): number { return Math.round((stop ?? performance.now()) - start); } -function buildCommandString(command: string, args: string[] = []): string { +export function buildCommandString( + command: string, + args: string[] = [], +): string { if (args.length === 0) { return command; } @@ -169,9 +172,6 @@ export function executeProcess(cfg: ProcessConfig): Promise { const date = new Date().toISOString(); const start = performance.now(); - // Build the complete command string - const commandString = buildCommandString(command, args ?? []); - ui().logger.log( gray( `Executing command:\n${formatCommandLog({ @@ -193,6 +193,8 @@ export function executeProcess(cfg: ProcessConfig): Promise { } return new Promise((resolve, reject) => { + const commandString = buildCommandString(command, args ?? []); + const childProcess = exec(commandString, { cwd, env: env ? { ...process.env, ...env } : process.env, diff --git a/packages/nx-plugin/src/internal/execute-process.unit.test.ts b/packages/nx-plugin/src/internal/execute-process.unit.test.ts index a3a041d9b..47397994f 100644 --- a/packages/nx-plugin/src/internal/execute-process.unit.test.ts +++ b/packages/nx-plugin/src/internal/execute-process.unit.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getAsyncProcessRunnerConfig } from '@code-pushup/test-utils'; -import { type ProcessObserver, executeProcess } from './execute-process.js'; +import { + type ProcessObserver, + buildCommandString, + executeProcess, +} from './execute-process.js'; describe('executeProcess', () => { const spyObserver: ProcessObserver = { @@ -90,3 +94,43 @@ describe('executeProcess', () => { expect(spyObserver.onComplete).toHaveBeenCalledOnce(); }); }); + +describe('buildCommandString', () => { + it('should return command when no args provided', () => { + expect(buildCommandString('node')).toBe('node'); + }); + + it('should return command when empty args array provided', () => { + expect(buildCommandString('node', [])).toBe('node'); + }); + + it('should return command with simple args', () => { + expect(buildCommandString('npm', ['install', 'package'])).toBe( + 'npm install package', + ); + }); + + it('should escape args with spaces', () => { + expect(buildCommandString('echo', ['hello world'])).toBe( + 'echo "hello world"', + ); + }); + + it('should escape args with double quotes for shell safety and cross-platform compatibility', () => { + expect(buildCommandString('echo', ['say "hello"'])).toBe( + 'echo "say \\"hello\\""', + ); + }); + + it('should escape args with single quotes for shell safety and cross-platform compatibility', () => { + expect(buildCommandString('echo', ["it's working"])).toBe( + 'echo "it\'s working"', + ); + }); + + it('should handle mixed escaped and non-escaped args', () => { + expect( + buildCommandString('node', ['script.js', 'hello world', '--flag']), + ).toBe('node script.js "hello world" --flag'); + }); +}); From 41f9af20af4526a0af1d2fa2ac6bb2fb30f236e5 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 7 Sep 2025 00:57:00 +0200 Subject: [PATCH 27/48] refactor: wip --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abe381f57..45bbbf0af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,8 @@ Edit or add the environment variable there as follows: `INCLUDE_SLOW_TESTS=true` ### Executing local code +We use the current version of CodePushup and its plugins, to measure itself and its plugins. + _Execute the latest CLI source_ ```jsonc From 9dff7c4d00e924daedd72b5ffe7e04af17500959 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 7 Sep 2025 01:45:19 +0200 Subject: [PATCH 28/48] refactor: revert versions change --- packages/nx-plugin/src/internal/versions.ts | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/nx-plugin/src/internal/versions.ts b/packages/nx-plugin/src/internal/versions.ts index 7fb570401..b7e24f64a 100644 --- a/packages/nx-plugin/src/internal/versions.ts +++ b/packages/nx-plugin/src/internal/versions.ts @@ -1,4 +1,25 @@ -export const cpNxPluginVersion = 'latest'; -export const cpModelVersion = 'latest'; -export const cpUtilsVersion = 'latest'; -export const cpCliVersion = 'latest'; +import { readJsonFile } from '@nx/devkit'; +import * as path from 'node:path'; +import type { PackageJson } from 'nx/src/utils/package-json'; + +const workspaceRoot = path.join(__dirname, '../../'); +const projectsFolder = path.join(__dirname, '../../../'); + +export const cpNxPluginVersion = loadPackageJson(workspaceRoot).version; +export const cpModelVersion = loadPackageJson( + path.join(projectsFolder, 'cli'), +).version; +export const cpUtilsVersion = loadPackageJson( + path.join(projectsFolder, 'utils'), +).version; +export const cpCliVersion = loadPackageJson( + path.join(projectsFolder, 'models'), +).version; + +/** + * Load the package.json file from the given folder path. + * @param folderPath + */ +function loadPackageJson(folderPath: string): PackageJson { + return readJsonFile(path.join(folderPath, 'package.json')); +} From 0f3ba3ad372564d19b55785d05ba093dd24f5f6e Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:18:07 +0200 Subject: [PATCH 29/48] Update CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matěj Chalk <34691111+matejchalk@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45bbbf0af..d599cb1ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ Edit or add the environment variable there as follows: `INCLUDE_SLOW_TESTS=true` ### Executing local code -We use the current version of CodePushup and its plugins, to measure itself and its plugins. +We use the current version of Code PushUp and its plugins, to measure itself and its plugins. _Execute the latest CLI source_ From cefe54d938f8682c1022cec8f6c7e9fb0552dc80 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:19:26 +0200 Subject: [PATCH 30/48] Update packages/nx-plugin/src/executors/cli/executor.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matěj Chalk <34691111+matejchalk@users.noreply.github.com> --- packages/nx-plugin/src/executors/cli/executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 0b8f6e587..5b957421d 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -52,7 +52,7 @@ export default async function runAutorunExecutor( return { success: false, command: commandString, - error: error as Error, + error: error instanceof Error ? error : new Error(stringifyError(error)), }; } From d9b453f473f77657165750c82960ced9eb31ea7e Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:23:11 +0200 Subject: [PATCH 31/48] Update CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matěj Chalk <34691111+matejchalk@users.noreply.github.com> --- CONTRIBUTING.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d599cb1ba..1d0febb09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,18 +79,11 @@ _Execute the latest CLI source_ // project.json { "targets": { - "exec-local-cli-source": { + "code-pushup": { "executor": "nx:run-commands", "options": { "command": "node packages/cli/src/index.ts", - "args": ["--no-progress", "--verbose", "--help"], - } - }, - "exec-local-cli-source-and-local-plugin-source": { - "executor": "nx:run-commands", - "options": { - "command": "node packages/cli/src/index.ts", - "args": ["--no-progress", "--verbose", "--onlyPlugins=js-packages"], + "args": ["--no-progress", "--verbose"], "env": { "NODE_OPTIONS": "--import tsx", "TSX_TSCONFIG_PATH": "tsconfig.base.json" From 7b307d05e47443656bf1f6e84bbf5319c49ce5d7 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:28:26 +0200 Subject: [PATCH 32/48] Update CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matěj Chalk <34691111+matejchalk@users.noreply.github.com> --- CONTRIBUTING.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d0febb09..a0347fb0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,6 @@ _Setup code-pushup targets with the nx plugin_ "options": { "cliBin": "node ./packages/cli/src/index.ts", "env": { - // nx.json "NODE_OPTIONS": "--import=tsx", "TSX_TSCONFIG_PATH": "tsconfig.base.json", }, From a2892ba86d9259d6fa037f5226736505378c65a7 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 16:44:45 +0200 Subject: [PATCH 33/48] refactor: move logic into command.ts --- packages/utils/src/lib/command.int.test.ts | 104 ++++++++ packages/utils/src/lib/command.ts | 151 +++++++++++ packages/utils/src/lib/command.unit.test.ts | 236 ++++++++++++++++++ packages/utils/src/lib/file-system.ts | 5 - .../utils/src/lib/file-system.unit.test.ts | 10 +- .../src/lib/format-command-log.int.test.ts | 61 ----- packages/utils/src/lib/format-command-log.ts | 27 -- packages/utils/src/lib/transform.ts | 62 ----- packages/utils/src/lib/transform.unit.test.ts | 68 ----- 9 files changed, 492 insertions(+), 232 deletions(-) create mode 100644 packages/utils/src/lib/command.int.test.ts create mode 100644 packages/utils/src/lib/command.ts create mode 100644 packages/utils/src/lib/command.unit.test.ts delete mode 100644 packages/utils/src/lib/format-command-log.int.test.ts delete mode 100644 packages/utils/src/lib/format-command-log.ts diff --git a/packages/utils/src/lib/command.int.test.ts b/packages/utils/src/lib/command.int.test.ts new file mode 100644 index 000000000..f8590d63d --- /dev/null +++ b/packages/utils/src/lib/command.int.test.ts @@ -0,0 +1,104 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { removeColorCodes } from '@code-pushup/test-utils'; +import { formatCommandLog } from './command.js'; + +describe('formatCommandLog', () => { + it('should format simple command', () => { + const result = removeColorCodes( + formatCommandLog({ command: 'npx', args: ['command', '--verbose'] }), + ); + + expect(result).toBe('$ npx command --verbose'); + }); + + it('should format simple command with explicit process.cwd()', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: process.cwd(), + }), + ); + + expect(result).toBe('$ npx command --verbose'); + }); + + it('should format simple command with relative cwd', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: './wololo', + }), + ); + + expect(result).toBe(`wololo $ npx command --verbose`); + }); + + it('should format simple command with absolute non-current path converted to relative', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: path.join(process.cwd(), 'tmp'), + }), + ); + expect(result).toBe('tmp $ npx command --verbose'); + }); + + it('should format simple command with relative cwd in parent folder', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: '..', + }), + ); + + expect(result).toBe(`.. $ npx command --verbose`); + }); + + it('should format simple command using relative path to parent directory', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: path.dirname(process.cwd()), + }), + ); + + expect(result).toBe('.. $ npx command --verbose'); + }); + + it('should format command with environment variables', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command'], + cwd: process.cwd(), + env: { + NODE_ENV: 'production', + DEBUG: 'true', + }, + }), + ); + + expect(result).toBe('$ NODE_ENV=production DEBUG=true npx command'); + }); + + it('should handle environment variables with quotes in values', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command'], + cwd: process.cwd(), + env: { + MESSAGE: 'Hello "world"', + }, + }), + ); + + expect(result).toBe('$ MESSAGE="Hello world" npx command'); + }); +}); diff --git a/packages/utils/src/lib/command.ts b/packages/utils/src/lib/command.ts new file mode 100644 index 000000000..3a4970b7c --- /dev/null +++ b/packages/utils/src/lib/command.ts @@ -0,0 +1,151 @@ +import ansis from 'ansis'; +import path from 'node:path'; + +type ArgumentValue = number | string | boolean | string[]; +export type CliArgsObject> = + T extends never + ? Record | { _: string } + : T; + +/** + * Escapes command line arguments that contain spaces, quotes, or other special characters. + * + * @param {string[]} args - Array of command arguments to escape. + * @returns {string[]} - Array of escaped arguments suitable for shell execution. + */ +export function escapeCliArgs(args: string[]): string[] { + return args.map(arg => { + if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; + }); +} + +/** + * Formats environment variable values for display by stripping quotes and then escaping. + * + * @param {string} value - Environment variable value to format. + * @returns {string} - Formatted and escaped value suitable for display. + */ +export function formatEnvValue(value: string): string { + // Strip quotes from the value for display + const cleanValue = value.replace(/"/g, ''); + return escapeCliArgs([cleanValue])[0] ?? cleanValue; +} + +/** + * Builds a command string by escaping arguments that contain spaces, quotes, or other special characters. + * + * @param {string} command - The base command to execute. + * @param {string[]} args - Array of command arguments. + * @returns {string} - The complete command string with properly escaped arguments. + */ +export function buildCommandString( + command: string, + args: string[] = [], +): string { + if (args.length === 0) { + return command; + } + + return `${command} ${escapeCliArgs(args).join(' ')}`; +} + +/** + * Options for formatting a command log. + */ +export interface FormatCommandLogOptions { + command: string; + args?: string[]; + cwd?: string; + env?: Record; +} + +/** + * Formats a command string with optional cwd prefix, environment variables, and ANSI colors. + * + * @param {FormatCommandLogOptions} options - Command formatting options. + * @returns {string} - ANSI-colored formatted command string. + */ +export function formatCommandLog(options: FormatCommandLogOptions): string { + const { command, args = [], cwd = process.cwd(), env } = options; + const relativeDir = path.relative(process.cwd(), cwd); + + return [ + ...(relativeDir && relativeDir !== '.' + ? [ansis.italic(ansis.gray(relativeDir))] + : []), + ansis.yellow('$'), + ...(env && Object.keys(env).length > 0 + ? Object.entries(env).map(([key, value]) => { + return ansis.gray(`${key}=${formatEnvValue(value)}`); + }) + : []), + ansis.gray(command), + ansis.gray(escapeCliArgs(args).join(' ')), + ].join(' '); +} + +/** + * Converts an object with different types of values into an array of command-line arguments. + * + * @example + * const args = objectToCliArgs({ + * _: ['node', 'index.js'], // node index.js + * name: 'Juanita', // --name=Juanita + * formats: ['json', 'md'] // --format=json --format=md + * }); + */ +export function objectToCliArgs< + T extends object = Record, +>(params?: CliArgsObject): string[] { + if (!params) { + return []; + } + + return Object.entries(params).flatMap(([key, value]) => { + // process/file/script + if (key === '_') { + return Array.isArray(value) ? value : [`${value}`]; + } + const prefix = key.length === 1 ? '-' : '--'; + // "-*" arguments (shorthands) + if (Array.isArray(value)) { + return value.map(v => `${prefix}${key}="${v}"`); + } + // "--*" arguments ========== + + if (typeof value === 'object') { + return Object.entries(value as Record).flatMap( + // transform nested objects to the dot notation `key.subkey` + ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), + ); + } + + 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}`]; + } + + throw new Error(`Unsupported type ${typeof value} for key ${key}`); + }); +} + +/** + * Converts a file path to a CLI argument by wrapping it in quotes to handle spaces. + * + * @param {string} filePath - The file path to convert to a CLI argument. + * @returns {string} - The quoted file path suitable for CLI usage. + */ +export function filePathToCliArg(filePath: string): string { + // needs to be escaped if spaces included + return `"${filePath}"`; +} diff --git a/packages/utils/src/lib/command.unit.test.ts b/packages/utils/src/lib/command.unit.test.ts new file mode 100644 index 000000000..bab3287a9 --- /dev/null +++ b/packages/utils/src/lib/command.unit.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; +import { + buildCommandString, + escapeCliArgs, + filePathToCliArg, + objectToCliArgs, +} from './command.js'; + +describe('filePathToCliArg', () => { + it('should wrap path in quotes', () => { + expect(filePathToCliArg('My Project/index.js')).toBe( + '"My Project/index.js"', + ); + }); +}); + +describe('escapeCliArgs', () => { + it('should return empty array for empty input', () => { + const args: string[] = []; + const result = escapeCliArgs(args); + expect(result).toEqual([]); + }); + + it('should return arguments unchanged when no special characters', () => { + const args = ['simple', 'arguments', '--flag', 'value']; + const result = escapeCliArgs(args); + expect(result).toEqual(['simple', 'arguments', '--flag', 'value']); + }); + + it('should escape arguments containing spaces', () => { + const args = ['file with spaces.txt', 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"file with spaces.txt"', 'normal']); + }); + + it('should escape arguments containing double quotes', () => { + const args = ['say "hello"', 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"say \\"hello\\""', 'normal']); + }); + + it('should escape arguments containing single quotes', () => { + const args = ["don't", 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"don\'t"', 'normal']); + }); + + it('should escape arguments containing both quote types', () => { + const args = ['mixed "double" and \'single\' quotes']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"mixed \\"double\\" and \'single\' quotes"']); + }); + + it('should escape arguments containing multiple spaces', () => { + const args = ['multiple spaces here']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"multiple spaces here"']); + }); + + it('should handle empty string arguments', () => { + const args = ['', 'normal', '']; + const result = escapeCliArgs(args); + expect(result).toEqual(['', 'normal', '']); + }); + + it('should handle arguments with only spaces', () => { + const args = [' ', 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['" "', 'normal']); + }); + + it('should handle complex mix of arguments', () => { + const args = [ + 'simple', + 'with spaces', + 'with"quotes', + "with'apostrophe", + '--flag', + 'value', + ]; + const result = escapeCliArgs(args); + expect(result).toEqual([ + 'simple', + '"with spaces"', + '"with\\"quotes"', + '"with\'apostrophe"', + '--flag', + 'value', + ]); + }); + + it('should handle arguments with consecutive quotes', () => { + const args = ['""""', "''''"]; + const result = escapeCliArgs(args); + expect(result).toEqual(['"\\"\\"\\"\\""', "\"''''\""]); + }); +}); + +describe('objectToCliArgs', () => { + 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 nested objects', () => { + const params = { persist: { format: ['json', 'md'], verbose: false } }; + const result = objectToCliArgs(params); + expect(result).toEqual([ + '--persist.format="json"', + '--persist.format="md"', + '--no-persist.verbose', + ]); + }); + + it('should throw error for unsupported type', () => { + const params = { unsupported: undefined as any }; + expect(() => objectToCliArgs(params)).toThrow('Unsupported type'); + }); +}); + +describe('buildCommandString', () => { + it('should return command only when no arguments provided', () => { + const command = 'npm'; + const result = buildCommandString(command); + expect(result).toBe('npm'); + }); + + it('should return command only when empty arguments array provided', () => { + const command = 'npm'; + const result = buildCommandString(command, []); + expect(result).toBe('npm'); + }); + + it('should handle simple arguments without special characters', () => { + const command = 'npm'; + const args = ['install', '--save-dev', 'vitest']; + const result = buildCommandString(command, args); + expect(result).toBe('npm install --save-dev vitest'); + }); + + it('should escape arguments containing spaces', () => { + const command = 'code'; + const args = ['My Project/index.js']; + const result = buildCommandString(command, args); + expect(result).toBe('code "My Project/index.js"'); + }); + + it('should escape arguments containing double quotes', () => { + const command = 'echo'; + const args = ['Hello "World"']; + const result = buildCommandString(command, args); + expect(result).toBe('echo "Hello \\"World\\""'); + }); + + it('should escape arguments containing single quotes', () => { + const command = 'echo'; + const args = ["Hello 'World'"]; + const result = buildCommandString(command, args); + expect(result).toBe('echo "Hello \'World\'"'); + }); + + it('should handle mixed arguments with and without special characters', () => { + const command = 'mycommand'; + const args = ['simple', 'with spaces', '--flag', 'with "quotes"']; + const result = buildCommandString(command, args); + expect(result).toBe( + 'mycommand simple "with spaces" --flag "with \\"quotes\\""', + ); + }); + + it('should handle arguments with multiple types of quotes', () => { + const command = 'test'; + const args = ['arg with "double" and \'single\' quotes']; + const result = buildCommandString(command, args); + expect(result).toBe('test "arg with \\"double\\" and \'single\' quotes"'); + }); + + it('should handle empty string arguments', () => { + const command = 'test'; + const args = ['', 'normal']; + const result = buildCommandString(command, args); + expect(result).toBe('test normal'); + }); + + it('should handle arguments with only spaces', () => { + const command = 'test'; + const args = [' ']; + const result = buildCommandString(command, args); + expect(result).toBe('test " "'); + }); +}); diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 268eed737..c04560edb 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -167,11 +167,6 @@ export function findLineNumberInText( return lineNumber === 0 ? null : lineNumber; // If the package isn't found, return null } -export function filePathToCliArg(filePath: string): string { - // needs to be escaped if spaces included - return `"${filePath}"`; -} - export function projectToFilename(project: string): string { return project.replace(/[/\\\s]+/g, '-').replace(/@/g, ''); } diff --git a/packages/utils/src/lib/file-system.unit.test.ts b/packages/utils/src/lib/file-system.unit.test.ts index b141ee4ff..be3c1cc5f 100644 --- a/packages/utils/src/lib/file-system.unit.test.ts +++ b/packages/utils/src/lib/file-system.unit.test.ts @@ -3,12 +3,12 @@ import { stat } from 'node:fs/promises'; import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { filePathToCliArg } from './command.js'; import { type FileResult, crawlFileSystem, createReportPath, ensureDirectoryExists, - filePathToCliArg, findLineNumberInText, findNearestFile, logMultipleFileResults, @@ -270,14 +270,6 @@ describe('findLineNumberInText', () => { }); }); -describe('filePathToCliArg', () => { - it('should wrap path in quotes', () => { - expect(filePathToCliArg('My Project/index.js')).toBe( - '"My Project/index.js"', - ); - }); -}); - describe('projectToFilename', () => { it.each([ ['frontend', 'frontend'], diff --git a/packages/utils/src/lib/format-command-log.int.test.ts b/packages/utils/src/lib/format-command-log.int.test.ts deleted file mode 100644 index 28a916a55..000000000 --- a/packages/utils/src/lib/format-command-log.int.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { removeColorCodes } from '@code-pushup/test-utils'; -import { formatCommandLog } from './format-command-log.js'; - -describe('formatCommandLog', () => { - it('should format simple command', () => { - const result = removeColorCodes( - formatCommandLog('npx', ['command', '--verbose']), - ); - - expect(result).toBe('$ npx command --verbose'); - }); - - it('should format simple command with explicit process.cwd()', () => { - const result = removeColorCodes( - formatCommandLog('npx', ['command', '--verbose'], process.cwd()), - ); - - expect(result).toBe('$ npx command --verbose'); - }); - - it('should format simple command with relative cwd', () => { - const result = removeColorCodes( - formatCommandLog('npx', ['command', '--verbose'], './wololo'), - ); - - expect(result).toBe(`wololo $ npx command --verbose`); - }); - - it('should format simple command with absolute non-current path converted to relative', () => { - const result = removeColorCodes( - formatCommandLog( - 'npx', - ['command', '--verbose'], - path.join(process.cwd(), 'tmp'), - ), - ); - expect(result).toBe('tmp $ npx command --verbose'); - }); - - it('should format simple command with relative cwd in parent folder', () => { - const result = removeColorCodes( - formatCommandLog('npx', ['command', '--verbose'], '..'), - ); - - expect(result).toBe(`.. $ npx command --verbose`); - }); - - it('should format simple command using relative path to parent directory', () => { - const result = removeColorCodes( - formatCommandLog( - 'npx', - ['command', '--verbose'], - path.dirname(process.cwd()), - ), - ); - - expect(result).toBe('.. $ npx command --verbose'); - }); -}); diff --git a/packages/utils/src/lib/format-command-log.ts b/packages/utils/src/lib/format-command-log.ts deleted file mode 100644 index 0ce5a89cd..000000000 --- a/packages/utils/src/lib/format-command-log.ts +++ /dev/null @@ -1,27 +0,0 @@ -import ansis from 'ansis'; -import path from 'node:path'; - -/** - * Formats a command string with optional cwd prefix and ANSI colors. - * - * @param {string} command - The command to execute. - * @param {string[]} args - Array of command arguments. - * @param {string} [cwd] - Optional current working directory for the command. - * @returns {string} - ANSI-colored formatted command string. - */ -export function formatCommandLog( - command: string, - args: string[] = [], - cwd: string = process.cwd(), -): string { - const relativeDir = path.relative(process.cwd(), cwd); - - return [ - ...(relativeDir && relativeDir !== '.' - ? [ansis.italic(ansis.gray(relativeDir))] - : []), - ansis.yellow('$'), - ansis.gray(command), - ansis.gray(args.map(arg => arg).join(' ')), - ].join(' '); -} diff --git a/packages/utils/src/lib/transform.ts b/packages/utils/src/lib/transform.ts index 86caf9aef..7c56a859c 100644 --- a/packages/utils/src/lib/transform.ts +++ b/packages/utils/src/lib/transform.ts @@ -44,68 +44,6 @@ export function factorOf(items: T[], filterFn: (i: T) => boolean): number { return filterCount === 0 ? 1 : (itemCount - filterCount) / itemCount; } -type ArgumentValue = number | string | boolean | string[]; -export type CliArgsObject> = - T extends never - ? Record | { _: string } - : T; - -/** - * Converts an object with different types of values into an array of command-line arguments. - * - * @example - * const args = objectToCliArgs({ - * _: ['node', 'index.js'], // node index.js - * name: 'Juanita', // --name=Juanita - * formats: ['json', 'md'] // --format=json --format=md - * }); - */ -export function objectToCliArgs< - T extends object = Record, ->(params?: CliArgsObject): string[] { - if (!params) { - return []; - } - - return Object.entries(params).flatMap(([key, value]) => { - // process/file/script - if (key === '_') { - return Array.isArray(value) ? value : [`${value}`]; - } - const prefix = key.length === 1 ? '-' : '--'; - // "-*" arguments (shorthands) - if (Array.isArray(value)) { - return value.map(v => `${prefix}${key}="${v}"`); - } - // "--*" arguments ========== - - if (Array.isArray(value)) { - return value.map(v => `${prefix}${key}="${v}"`); - } - - if (typeof value === 'object') { - return Object.entries(value as Record).flatMap( - // transform nested objects to the dot notation `key.subkey` - ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), - ); - } - - 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}`]; - } - - throw new Error(`Unsupported type ${typeof value} for key ${key}`); - }); -} - export function toUnixPath(path: string): string { return path.replace(/\\/g, '/'); } diff --git a/packages/utils/src/lib/transform.unit.test.ts b/packages/utils/src/lib/transform.unit.test.ts index b72982e3d..b8526504a 100644 --- a/packages/utils/src/lib/transform.unit.test.ts +++ b/packages/utils/src/lib/transform.unit.test.ts @@ -6,7 +6,6 @@ import { factorOf, fromJsonLines, objectFromEntries, - objectToCliArgs, objectToEntries, objectToKeys, removeUndefinedAndEmptyProps, @@ -165,73 +164,6 @@ describe('factorOf', () => { }); }); -describe('objectToCliArgs', () => { - 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 nested objects', () => { - const params = { persist: { format: ['json', 'md'], verbose: false } }; - const result = objectToCliArgs(params); - expect(result).toEqual([ - '--persist.format="json"', - '--persist.format="md"', - '--no-persist.verbose', - ]); - }); - - it('should throw error for unsupported type', () => { - const params = { unsupported: undefined as any }; - expect(() => objectToCliArgs(params)).toThrow('Unsupported type'); - }); -}); - describe('toUnixPath', () => { it.each([ ['main.ts', 'main.ts'], From ff74241007895ff95e5789d2f355b27dc6fe6ed8 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 17:47:41 +0200 Subject: [PATCH 34/48] refactor: adjust tests --- .../src/executors/internal/cli.unit.test.ts | 105 +----------- packages/nx-plugin/src/index.ts | 2 +- packages/nx-plugin/src/internal/command.ts | 155 ++++++++++++++++++ .../nx-plugin/src/internal/execute-process.ts | 84 +++------- .../src/internal/execute-process.unit.test.ts | 136 --------------- .../src/plugin/target/configuration-target.ts | 2 +- packages/utils/src/index.ts | 12 +- packages/utils/src/lib/command.ts | 6 +- packages/utils/src/lib/command.unit.test.ts | 16 +- packages/utils/src/lib/execute-process.ts | 69 ++++---- 10 files changed, 248 insertions(+), 339 deletions(-) create mode 100644 packages/nx-plugin/src/internal/command.ts delete mode 100644 packages/nx-plugin/src/internal/execute-process.unit.test.ts diff --git a/packages/nx-plugin/src/executors/internal/cli.unit.test.ts b/packages/nx-plugin/src/executors/internal/cli.unit.test.ts index b8251484c..b889a0bd0 100644 --- a/packages/nx-plugin/src/executors/internal/cli.unit.test.ts +++ b/packages/nx-plugin/src/executors/internal/cli.unit.test.ts @@ -1,108 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - createCliCommandObject, - createCliCommandString, - objectToCliArgs, -} from './cli.js'; - -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 nested objects', () => { - const params = { persist: { format: ['json', 'md'], verbose: false } }; - const result = objectToCliArgs(params); - expect(result).toEqual([ - '--persist.format="json"', - '--persist.format="md"', - '--no-persist.verbose', - ]); - }); - - 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('createCliCommandString', () => { - it('should create command out of object for arguments', () => { - const result = createCliCommandString({ args: { verbose: true } }); - expect(result).toBe('npx @code-pushup/cli --verbose'); - }); - - it('should create command out of object for arguments with positional', () => { - const result = createCliCommandString({ - args: { _: 'autorun', verbose: true }, - }); - expect(result).toBe('npx @code-pushup/cli autorun --verbose'); - }); -}); +import { createCliCommandObject } from './cli.js'; describe('createCliCommandObject', () => { it('should create command out of object for arguments', () => { diff --git a/packages/nx-plugin/src/index.ts b/packages/nx-plugin/src/index.ts index 356be758c..7571e7f53 100644 --- a/packages/nx-plugin/src/index.ts +++ b/packages/nx-plugin/src/index.ts @@ -11,7 +11,7 @@ const plugin = { export default plugin; export type { AutorunCommandExecutorOptions } from './executors/cli/schema.js'; -export { objectToCliArgs } from './executors/internal/cli.js'; +export { objectToCliArgs } from './internal/command.js'; export { generateCodePushupConfig } from './generators/configuration/code-pushup-config.js'; export { configurationGenerator } from './generators/configuration/generator.js'; export type { ConfigurationGeneratorOptions } from './generators/configuration/schema.js'; diff --git a/packages/nx-plugin/src/internal/command.ts b/packages/nx-plugin/src/internal/command.ts new file mode 100644 index 000000000..34a40ee6c --- /dev/null +++ b/packages/nx-plugin/src/internal/command.ts @@ -0,0 +1,155 @@ +import ansis from 'ansis'; +import path from 'node:path'; + +type ArgumentValue = number | string | boolean | string[] | undefined; +export type CliArgsObject> = + T extends never + ? Record | { _: string } + : T; + +/** + * Escapes command line arguments that contain spaces, quotes, or other special characters. + * + * @param {string[]} args - Array of command arguments to escape. + * @returns {string[]} - Array of escaped arguments suitable for shell execution. + */ +export function escapeCliArgs(args: string[]): string[] { + return args.map(arg => { + if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; + }); +} + +/** + * Formats environment variable values for display by stripping quotes and then escaping. + * + * @param {string} value - Environment variable value to format. + * @returns {string} - Formatted and escaped value suitable for display. + */ +export function formatEnvValue(value: string): string { + // Strip quotes from the value for display + const cleanValue = value.replace(/"/g, ''); + return escapeCliArgs([cleanValue])[0] ?? cleanValue; +} + +/** + * Builds a command string by escaping arguments that contain spaces, quotes, or other special characters. + * + * @param {string} command - The base command to execute. + * @param {string[]} args - Array of command arguments. + * @returns {string} - The complete command string with properly escaped arguments. + */ +export function buildCommandString( + command: string, + args: string[] = [], +): string { + if (args.length === 0) { + return command; + } + + return `${command} ${escapeCliArgs(args).join(' ')}`; +} + +/** + * Options for formatting a command log. + */ +export interface FormatCommandLogOptions { + command: string; + args?: string[]; + cwd?: string; + env?: Record; +} + +/** + * Formats a command string with optional cwd prefix, environment variables, and ANSI colors. + * + * @param {FormatCommandLogOptions} options - Command formatting options. + * @returns {string} - ANSI-colored formatted command string. + */ +export function formatCommandLog(options: FormatCommandLogOptions): string { + const { command, args = [], cwd = process.cwd(), env } = options; + const relativeDir = path.relative(process.cwd(), cwd); + + return [ + ...(relativeDir && relativeDir !== '.' + ? [ansis.italic(ansis.gray(relativeDir))] + : []), + ansis.yellow('$'), + ...(env && Object.keys(env).length > 0 + ? Object.entries(env).map(([key, value]) => { + return ansis.gray(`${key}=${formatEnvValue(value)}`); + }) + : []), + ansis.gray(command), + ansis.gray(escapeCliArgs(args).join(' ')), + ].join(' '); +} + +/** + * Converts an object with different types of values into an array of command-line arguments. + * + * @example + * const args = objectToCliArgs({ + * _: ['node', 'index.js'], // node index.js + * name: 'Juanita', // --name=Juanita + * formats: ['json', 'md'] // --format=json --format=md + * }); + */ +export function objectToCliArgs< + T extends object = Record, +>(params?: CliArgsObject): string[] { + if (!params) { + return []; + } + + return Object.entries(params).flatMap(([key, value]) => { + // process/file/script + if (key === '_') { + return Array.isArray(value) ? value : [`${value}`]; + } + const prefix = key.length === 1 ? '-' : '--'; + // "-*" arguments (shorthands) + if (Array.isArray(value)) { + return value.map(v => `${prefix}${key}="${v}"`); + } + // "--*" arguments ========== + + if (typeof value === 'object') { + return Object.entries(value as Record).flatMap( + // transform nested objects to the dot notation `key.subkey` + ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), + ); + } + + 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 (typeof value === 'undefined') { + return []; + } + + throw new Error(`Unsupported type ${typeof value} for key ${key}`); + }); +} + +/** + * Converts a file path to a CLI argument by wrapping it in quotes to handle spaces. + * + * @param {string} filePath - The file path to convert to a CLI argument. + * @returns {string} - The quoted file path suitable for CLI usage. + */ +export function filePathToCliArg(filePath: string): string { + // needs to be escaped if spaces included + return `"${filePath}"`; +} diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index 461066adf..53584d110 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -1,29 +1,10 @@ -import { gray } from 'ansis'; -import { exec } from 'node:child_process'; -import { ui } from '@code-pushup/utils'; -import { formatCommandLog } from '../executors/internal/cli.js'; - -export function calcDuration(start: number, stop?: number): number { - return Math.round((stop ?? performance.now()) - start); -} - -export function buildCommandString( - command: string, - args: string[] = [], -): string { - if (args.length === 0) { - return command; - } - - const escapedArgs = args.map(arg => { - if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { - return `"${arg.replace(/"/g, '\\"')}"`; - } - return arg; - }); - - return `${command} ${escapedArgs.join(' ')}`; -} +/* + * COPY OF @code-pusup/utils + * This is needed until dual-build is set up for all packages + */ +import { logger } from '@nx/devkit'; +import { type ChildProcess, exec } from 'node:child_process'; +import { buildCommandString, formatCommandLog } from './command.js'; /** * Represents the process result. @@ -37,8 +18,6 @@ export type ProcessResult = { stdout: string; stderr: string; code: number | null; - date: string; - duration: number; }; /** @@ -107,7 +86,6 @@ export type ProcessConfig = { cwd?: string; env?: Record; observer?: ProcessObserver; - dryRun?: boolean; ignoreExitCode?: boolean; }; @@ -121,11 +99,12 @@ export type ProcessConfig = { * * @example * const observer = { - * onStdout: (stdout) => console.info(stdout) + * onStdout: (stdout, childProcess) => console.info(stdout) * } */ export type ProcessObserver = { - onStdout?: (stdout: string) => void; + onStdout?: (stdout: string, childProcess: ChildProcess) => void; + onStderr?: (stderr: string, childProcess: ChildProcess) => void; onError?: (error: ProcessError) => void; onComplete?: () => void; }; @@ -146,7 +125,7 @@ export type ProcessObserver = { * // async process execution * const result = await executeProcess({ * command: 'node', - * args: ['download-data'], + * args: ['download-data.js'], * observer: { * onStdout: updateProgress, * error: handleError, @@ -158,7 +137,9 @@ export type ProcessObserver = { * * @param cfg - see {@link ProcessConfig} */ -export function executeProcess(cfg: ProcessConfig): Promise { +export function executeProcess( + cfg: ProcessConfig & { verbose?: boolean; dryRun?: boolean }, +): Promise { const { observer, cwd, @@ -166,30 +147,19 @@ export function executeProcess(cfg: ProcessConfig): Promise { args, ignoreExitCode = false, env, - dryRun, + verbose, } = cfg; - const { onStdout, onError, onComplete } = observer ?? {}; - const date = new Date().toISOString(); - const start = performance.now(); + const { onStdout, onStderr, onError, onComplete } = observer ?? {}; - ui().logger.log( - gray( - `Executing command:\n${formatCommandLog({ + if (verbose) { + logger.log( + formatCommandLog({ command, - args: args ?? [], + args, + cwd: cfg.cwd ?? process.cwd(), env, - })}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, - ), - ); - - if (dryRun) { - return Promise.resolve({ - code: 0, - stdout: '@code-pushup executed in dry run mode', - stderr: '', - date, - duration: calcDuration(start), - }); + }), + ); } return new Promise((resolve, reject) => { @@ -209,13 +179,14 @@ export function executeProcess(cfg: ProcessConfig): Promise { if (childProcess.stdout) { childProcess.stdout.on('data', data => { stdout += String(data); - onStdout?.(String(data)); + onStdout?.(String(data), childProcess); }); } if (childProcess.stderr) { childProcess.stderr.on('data', data => { stderr += String(data); + onStderr?.(String(data), childProcess); }); } @@ -224,12 +195,11 @@ export function executeProcess(cfg: ProcessConfig): Promise { }); childProcess.on('close', code => { - const timings = { date, duration: calcDuration(start) }; if (code === 0 || ignoreExitCode) { onComplete?.(); - resolve({ code, stdout, stderr, ...timings }); + resolve({ code, stdout, stderr }); } else { - const errorMsg = new ProcessError({ code, stdout, stderr, ...timings }); + const errorMsg = new ProcessError({ code, stdout, stderr }); onError?.(errorMsg); reject(errorMsg); } diff --git a/packages/nx-plugin/src/internal/execute-process.unit.test.ts b/packages/nx-plugin/src/internal/execute-process.unit.test.ts deleted file mode 100644 index 47397994f..000000000 --- a/packages/nx-plugin/src/internal/execute-process.unit.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getAsyncProcessRunnerConfig } from '@code-pushup/test-utils'; -import { - type ProcessObserver, - buildCommandString, - executeProcess, -} from './execute-process.js'; - -describe('executeProcess', () => { - const spyObserver: ProcessObserver = { - onStdout: vi.fn(), - onError: vi.fn(), - onComplete: vi.fn(), - }; - const errorSpy = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should work with node command `node -v`', async () => { - const processResult = await executeProcess({ - command: `node`, - args: ['-v'], - observer: spyObserver, - }); - - // Note: called once or twice depending on environment (2nd time for a new line) - expect(spyObserver.onStdout).toHaveBeenCalled(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(processResult.stdout).toMatch(/v\d{1,2}(\.\d{1,2}){0,2}/); - }); - - it('should work with npx command `npx --help`', async () => { - const processResult = await executeProcess({ - command: `npx`, - args: ['--help'], - observer: spyObserver, - }); - expect(spyObserver.onStdout).toHaveBeenCalledOnce(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(processResult.stdout).toContain('npm exec'); - }); - - it('should work with script `node custom-script.js`', async () => { - const processResult = await executeProcess({ - ...getAsyncProcessRunnerConfig({ interval: 10, runs: 4 }), - observer: spyObserver, - }).catch(errorSpy); - - expect(errorSpy).not.toHaveBeenCalled(); - expect(processResult.stdout).toContain('process:complete'); - expect(spyObserver.onStdout).toHaveBeenCalledTimes(6); // intro + 4 runs + complete - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - }); - - it('should work with async script `node custom-script.js` that throws an error', async () => { - const processResult = await executeProcess({ - ...getAsyncProcessRunnerConfig({ - interval: 10, - runs: 1, - throwError: true, - }), - observer: spyObserver, - }).catch(errorSpy); - - expect(errorSpy).toHaveBeenCalledOnce(); - expect(processResult).toBeUndefined(); - expect(spyObserver.onStdout).toHaveBeenCalledTimes(2); // intro + 1 run before error - expect(spyObserver.onError).toHaveBeenCalledOnce(); - expect(spyObserver.onComplete).not.toHaveBeenCalled(); - }); - - it('should successfully exit process after an error is thrown when ignoreExitCode is set', async () => { - const processResult = await executeProcess({ - ...getAsyncProcessRunnerConfig({ - interval: 10, - runs: 1, - throwError: true, - }), - observer: spyObserver, - ignoreExitCode: true, - }).catch(errorSpy); - - expect(errorSpy).not.toHaveBeenCalled(); - expect(processResult.code).toBe(1); - expect(processResult.stdout).toContain('process:update'); - expect(processResult.stderr).toContain('dummy-error'); - expect(spyObserver.onStdout).toHaveBeenCalledTimes(2); // intro + 1 run before error - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - }); -}); - -describe('buildCommandString', () => { - it('should return command when no args provided', () => { - expect(buildCommandString('node')).toBe('node'); - }); - - it('should return command when empty args array provided', () => { - expect(buildCommandString('node', [])).toBe('node'); - }); - - it('should return command with simple args', () => { - expect(buildCommandString('npm', ['install', 'package'])).toBe( - 'npm install package', - ); - }); - - it('should escape args with spaces', () => { - expect(buildCommandString('echo', ['hello world'])).toBe( - 'echo "hello world"', - ); - }); - - it('should escape args with double quotes for shell safety and cross-platform compatibility', () => { - expect(buildCommandString('echo', ['say "hello"'])).toBe( - 'echo "say \\"hello\\""', - ); - }); - - it('should escape args with single quotes for shell safety and cross-platform compatibility', () => { - expect(buildCommandString('echo', ["it's working"])).toBe( - 'echo "it\'s working"', - ); - }); - - it('should handle mixed escaped and non-escaped args', () => { - expect( - buildCommandString('node', ['script.js', 'hello world', '--flag']), - ).toBe('node script.js "hello world" --flag'); - }); -}); diff --git a/packages/nx-plugin/src/plugin/target/configuration-target.ts b/packages/nx-plugin/src/plugin/target/configuration-target.ts index 63c4fe57c..9868abfa2 100644 --- a/packages/nx-plugin/src/plugin/target/configuration-target.ts +++ b/packages/nx-plugin/src/plugin/target/configuration-target.ts @@ -1,6 +1,6 @@ import type { TargetConfiguration } from '@nx/devkit'; import type { RunCommandsOptions } from 'nx/src/executors/run-commands/run-commands.impl'; -import { objectToCliArgs } from '../../executors/internal/cli.js'; +import { objectToCliArgs } from '../../internal/command.js'; import { PACKAGE_NAME } from '../../internal/constants.js'; import { CP_TARGET_NAME } from '../constants.js'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 099273815..b8f95a0f6 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -8,6 +8,15 @@ export { toTitleCase, uppercase, } from './lib/case-conversions.js'; +export { + buildCommandString, + filePathToCliArg, + formatCommandLog, + type FormatCommandLogOptions, + formatEnvValue, + objectToCliArgs, + type CliArgsObject, +} from './lib/command.js'; export { filesCoverageToTree, type FileCoverage } from './lib/coverage-tree.js'; export { createRunnerFiles } from './lib/create-runner-files.js'; export { comparePairs, matchArrayItemsByKey, type Diff } from './lib/diff.js'; @@ -33,7 +42,6 @@ export { directoryExists, ensureDirectoryExists, fileExists, - filePathToCliArg, findLineNumberInText, findNearestFile, importModule, @@ -124,7 +132,6 @@ export { factorOf, fromJsonLines, objectFromEntries, - objectToCliArgs, objectToEntries, objectToKeys, removeUndefinedAndEmptyProps, @@ -134,7 +141,6 @@ export { toOrdinal, toUnixNewlines, toUnixPath, - type CliArgsObject, } from './lib/transform.js'; export type { CamelCaseToKebabCase, diff --git a/packages/utils/src/lib/command.ts b/packages/utils/src/lib/command.ts index 3a4970b7c..34a40ee6c 100644 --- a/packages/utils/src/lib/command.ts +++ b/packages/utils/src/lib/command.ts @@ -1,7 +1,7 @@ import ansis from 'ansis'; import path from 'node:path'; -type ArgumentValue = number | string | boolean | string[]; +type ArgumentValue = number | string | boolean | string[] | undefined; export type CliArgsObject> = T extends never ? Record | { _: string } @@ -135,6 +135,10 @@ export function objectToCliArgs< return [`${prefix}${value ? '' : 'no-'}${key}`]; } + if (typeof value === 'undefined') { + return []; + } + throw new Error(`Unsupported type ${typeof value} for key ${key}`); }); } diff --git a/packages/utils/src/lib/command.unit.test.ts b/packages/utils/src/lib/command.unit.test.ts index bab3287a9..6f2706fb3 100644 --- a/packages/utils/src/lib/command.unit.test.ts +++ b/packages/utils/src/lib/command.unit.test.ts @@ -97,6 +97,11 @@ describe('escapeCliArgs', () => { }); describe('objectToCliArgs', () => { + it('should handle undefined', () => { + const params = { unsupported: undefined as any }; + expect(objectToCliArgs(params)).toStrictEqual([]); + }); + it('should handle the "_" argument as script', () => { const params = { _: 'bin.js' }; const result = objectToCliArgs(params); @@ -158,8 +163,9 @@ describe('objectToCliArgs', () => { }); it('should throw error for unsupported type', () => { - const params = { unsupported: undefined as any }; - expect(() => objectToCliArgs(params)).toThrow('Unsupported type'); + expect(() => objectToCliArgs({ param: Symbol('') })).toThrow( + 'Unsupported type', + ); }); }); @@ -220,6 +226,12 @@ describe('buildCommandString', () => { expect(result).toBe('test "arg with \\"double\\" and \'single\' quotes"'); }); + it('should handle objects with undefined', () => { + const params = { format: undefined }; + const result = objectToCliArgs(params); + expect(result).toStrictEqual([]); + }); + it('should handle empty string arguments', () => { const command = 'test'; const args = ['', 'normal']; diff --git a/packages/utils/src/lib/execute-process.ts b/packages/utils/src/lib/execute-process.ts index d1fa98a3f..2cdc27922 100644 --- a/packages/utils/src/lib/execute-process.ts +++ b/packages/utils/src/lib/execute-process.ts @@ -1,13 +1,6 @@ -import { - type ChildProcess, - type ChildProcessByStdio, - type SpawnOptionsWithStdioTuple, - type StdioPipe, - spawn, -} from 'node:child_process'; -import type { Readable, Writable } from 'node:stream'; +import { type ChildProcess, exec } from 'node:child_process'; +import { buildCommandString, formatCommandLog } from './command.js'; import { isVerbose } from './env.js'; -import { formatCommandLog } from './format-command-log.js'; import { ui } from './logging.js'; import { calcDuration } from './reports/utils.js'; @@ -87,12 +80,11 @@ export class ProcessError extends Error { * args: ['--version'] * */ -export type ProcessConfig = Omit< - SpawnOptionsWithStdioTuple, - 'stdio' -> & { +export type ProcessConfig = { command: string; args?: string[]; + cwd?: string; + env?: Record; observer?: ProcessObserver; ignoreExitCode?: boolean; }; @@ -107,12 +99,12 @@ export type ProcessConfig = Omit< * * @example * const observer = { - * onStdout: (stdout) => console.info(stdout) + * onStdout: (stdout, childProcess) => console.info(stdout) * } */ export type ProcessObserver = { - onStdout?: (stdout: string, sourceProcess?: ChildProcess) => void; - onStderr?: (stderr: string, sourceProcess?: ChildProcess) => void; + onStdout?: (stdout: string, childProcess: ChildProcess) => void; + onStderr?: (stderr: string, childProcess: ChildProcess) => void; onError?: (error: ProcessError) => void; onComplete?: () => void; }; @@ -146,45 +138,54 @@ export type ProcessObserver = { * @param cfg - see {@link ProcessConfig} */ export function executeProcess(cfg: ProcessConfig): Promise { - const { command, args, observer, ignoreExitCode = false, ...options } = cfg; + const { observer, cwd, command, args, ignoreExitCode = false, env } = cfg; const { onStdout, onStderr, onError, onComplete } = observer ?? {}; const date = new Date().toISOString(); const start = performance.now(); if (isVerbose()) { ui().logger.log( - formatCommandLog(command, args, `${cfg.cwd ?? process.cwd()}`), + formatCommandLog({ + command, + args, + cwd: cfg.cwd ?? process.cwd(), + env, + }), ); } return new Promise((resolve, reject) => { - // shell:true tells Windows to use shell command for spawning a child process - const spawnedProcess = spawn(command, args ?? [], { - shell: true, - windowsHide: true, - ...options, - }) as ChildProcessByStdio; + const commandString = buildCommandString(command, args ?? []); + const childProcess = exec(commandString, { + cwd, + env: env ? { ...process.env, ...env } : process.env, + windowsHide: false, + }); // eslint-disable-next-line functional/no-let let stdout = ''; // eslint-disable-next-line functional/no-let let stderr = ''; - spawnedProcess.stdout.on('data', data => { - stdout += String(data); - onStdout?.(String(data), spawnedProcess); - }); + if (childProcess.stdout) { + childProcess.stdout.on('data', data => { + stdout += String(data); + onStdout?.(String(data), childProcess); + }); + } - spawnedProcess.stderr.on('data', data => { - stderr += String(data); - onStderr?.(String(data), spawnedProcess); - }); + if (childProcess.stderr) { + childProcess.stderr.on('data', data => { + stderr += String(data); + onStderr?.(String(data), childProcess); + }); + } - spawnedProcess.on('error', err => { + childProcess.on('error', err => { stderr += err.toString(); }); - spawnedProcess.on('close', code => { + childProcess.on('close', code => { const timings = { date, duration: calcDuration(start) }; if (code === 0 || ignoreExitCode) { onComplete?.(); From fc990a64c45b09de5bfb8fa0a4d88dc03ae2b776 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 21:36:25 +0200 Subject: [PATCH 35/48] refactor: fix build --- packages/nx-plugin/src/executors/cli/executor.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 5b957421d..c16d74908 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -3,13 +3,25 @@ import { executeProcess } from '../../internal/execute-process.js'; import { createCliCommandObject, createCliCommandString, - formatCommandLog, - objectToCliArgs, } from '../internal/cli.js'; import { normalizeContext } from '../internal/context.js'; import type { AutorunCommandExecutorOptions } from './schema.js'; import { mergeExecutorOptions, parseAutorunExecutorOptions } from './utils.js'; +export function stringifyError(error: unknown): string { + // TODO: special handling for ZodError instances + if (error instanceof Error) { + if (error.name === 'Error' || error.message.startsWith(error.name)) { + return error.message; + } + return `${error.name}: ${error.message}`; + } + if (typeof error === 'string') { + return error; + } + return JSON.stringify(error); +} + export type ExecutorOutput = { success: boolean; command?: string; From b1875d32e477368b7a8b06df0520e82750fda345 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 21:41:03 +0200 Subject: [PATCH 36/48] refactor: adjust models --- packages/nx-plugin/src/executors/internal/types.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/nx-plugin/src/executors/internal/types.ts b/packages/nx-plugin/src/executors/internal/types.ts index 677d472ce..03f5f44d1 100644 --- a/packages/nx-plugin/src/executors/internal/types.ts +++ b/packages/nx-plugin/src/executors/internal/types.ts @@ -15,18 +15,13 @@ export type GeneralExecutorOnlyOptions = { export type ProjectExecutorOnlyOptions = { projectPrefix?: string; }; - +type LooseAutocomplete = T | (string & {}); /** * CLI types that apply globally for all commands. */ -export type Command = - | 'collect' - | 'upload' - | 'autorun' - | 'print-config' - | 'compare' - | 'merge-diffs' - | 'history'; +export type Command = LooseAutocomplete< + 'collect' | 'upload' | 'autorun' | 'print-config' | 'compare' | 'merge-diffs' +>; export type GlobalExecutorOptions = { command?: Command; bin?: string; From 371e70bdea38441aa7ee223c68785cfeb5a32b77 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 21:52:11 +0200 Subject: [PATCH 37/48] refactor: adjust comments --- packages/utils/src/lib/command.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/utils/src/lib/command.ts b/packages/utils/src/lib/command.ts index 34a40ee6c..1adfa9a8b 100644 --- a/packages/utils/src/lib/command.ts +++ b/packages/utils/src/lib/command.ts @@ -67,6 +67,19 @@ export interface FormatCommandLogOptions { * * @param {FormatCommandLogOptions} options - Command formatting options. * @returns {string} - ANSI-colored formatted command string. + * + * @example + * + * formatCommandLog({cwd: 'tools/api', env: {API_KEY='•••' NODE_ENV='prod'}, command: 'node', args: ['cli.js', '--do', 'thing', 'fast']}) + * ┌──────────────────────────────────────────────────────────────────────────┐ + * │ tools/api $ API_KEY="•••" NODE_ENV="prod" node cli.js --do thing fast │ + * │ │ │ │ │ │ │ + * │ └ cwd │ │ │ └ args. │ + * │ │ │ └ command │ + * │ │ └ env variables │ + * │ └ prompt symbol ($) │ + * └──────────────────────────────────────────────────────────────────────────┘ + * */ export function formatCommandLog(options: FormatCommandLogOptions): string { const { command, args = [], cwd = process.cwd(), env } = options; From ab3f5edec6ce91d2532b25662f2b9646ea7f91d6 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 22:29:06 +0200 Subject: [PATCH 38/48] refactor: adjust comments --- packages/utils/src/lib/command.ts | 16 ++++++++-------- testing/test-nx-utils/src/lib/utils/nx.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/utils/src/lib/command.ts b/packages/utils/src/lib/command.ts index 1adfa9a8b..1eee3aba4 100644 --- a/packages/utils/src/lib/command.ts +++ b/packages/utils/src/lib/command.ts @@ -71,14 +71,14 @@ export interface FormatCommandLogOptions { * @example * * formatCommandLog({cwd: 'tools/api', env: {API_KEY='•••' NODE_ENV='prod'}, command: 'node', args: ['cli.js', '--do', 'thing', 'fast']}) - * ┌──────────────────────────────────────────────────────────────────────────┐ - * │ tools/api $ API_KEY="•••" NODE_ENV="prod" node cli.js --do thing fast │ - * │ │ │ │ │ │ │ - * │ └ cwd │ │ │ └ args. │ - * │ │ │ └ command │ - * │ │ └ env variables │ - * │ └ prompt symbol ($) │ - * └──────────────────────────────────────────────────────────────────────────┘ + * ┌─────────────────────────────────────────────────────────────────────────┐ + * │ tools/api $ API_KEY="•••" NODE_ENV="prod" node cli.js --do thing fast │ + * │ │ │ │ │ │ │ + * │ └ cwd │ │ │ └ args. │ + * │ │ │ └ command │ + * │ │ └ env variables │ + * │ └ prompt symbol ($) │ + * └─────────────────────────────────────────────────────────────────────────┘ * */ export function formatCommandLog(options: FormatCommandLogOptions): string { diff --git a/testing/test-nx-utils/src/lib/utils/nx.ts b/testing/test-nx-utils/src/lib/utils/nx.ts index 050bdc541..33a1ab69f 100644 --- a/testing/test-nx-utils/src/lib/utils/nx.ts +++ b/testing/test-nx-utils/src/lib/utils/nx.ts @@ -84,7 +84,7 @@ export async function nxShowProjectJson( ) { const { code, stderr, stdout } = await executeProcess({ command: 'npx', - args: ['nx', 'show', `project --json ${project}`], + args: ['nx', 'show', `project ${project} --json`], cwd, }); From aa0950abe7852dce2c154a3eca5424d2ce3ea62d Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 22:36:29 +0200 Subject: [PATCH 39/48] refactor: remove comments --- CONTRIBUTING.md | 44 -------------------------------------------- 1 file changed, 44 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0347fb0e..e8c8e23a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,50 +69,6 @@ You can control the execution of long-running tests over the `INCLUDE_SLOW_TESTS To change this setup, open (or create) the `.env` file in the root folder. Edit or add the environment variable there as follows: `INCLUDE_SLOW_TESTS=true`. -### Executing local code - -We use the current version of Code PushUp and its plugins, to measure itself and its plugins. - -_Execute the latest CLI source_ - -```jsonc -// project.json -{ - "targets": { - "code-pushup": { - "executor": "nx:run-commands", - "options": { - "command": "node packages/cli/src/index.ts", - "args": ["--no-progress", "--verbose"], - "env": { - "NODE_OPTIONS": "--import tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json" - } - } - } - } -``` - -_Setup code-pushup targets with the nx plugin_ - -```jsonc -// nx.json -{ - "plugins": [ - { - "plugin": "@code-pushup/nx-plugin", - "options": { - "cliBin": "node ./packages/cli/src/index.ts", - "env": { - "NODE_OPTIONS": "--import=tsx", - "TSX_TSCONFIG_PATH": "tsconfig.base.json", - }, - }, - }, - ], -} -``` - ## Git Commit messages must follow [conventional commits](https://conventionalcommits.org/) format. From 1d006f24deda39f8f889ccd0f63533a6af66e122 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Mon, 8 Sep 2025 23:46:34 +0200 Subject: [PATCH 40/48] refactor: fix e2e --- .../tests/plugin-create-nodes.e2e.test.ts | 10 ++++------ packages/nx-plugin/src/executors/cli/executor.ts | 2 +- packages/nx-plugin/src/internal/command.ts | 2 +- testing/test-nx-utils/src/lib/utils/nx.ts | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index 2508c2eeb..818073559 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -173,12 +173,10 @@ describe('nx-plugin', () => { }); const cleanStdout = removeColorCodes(stdout); - // @TODO create test environment for working plugin. This here misses package-lock.json to execute correctly - expect(cleanStdout).toContain('Executing command:'); - expect(cleanStdout).toContain('npx @code-pushup/cli'); - expect(cleanStdout).toContain( - 'NX Successfully ran target code-pushup for project my-lib', - ); + expect(cleanStdout).toContain('nx run my-lib:code-pushup'); + expect(cleanStdout).toContain('$ npx @code-pushup/cli '); + expect(cleanStdout).toContain('--dryRun --verbose'); + expect(cleanStdout).toContain(`--upload.project=\\"${project}\\"`); }); it('should consider plugin option cliBin in executor target', async () => { diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index c16d74908..f0b5fe30b 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -9,7 +9,6 @@ import type { AutorunCommandExecutorOptions } from './schema.js'; import { mergeExecutorOptions, parseAutorunExecutorOptions } from './utils.js'; export function stringifyError(error: unknown): string { - // TODO: special handling for ZodError instances if (error instanceof Error) { if (error.name === 'Error' || error.message.startsWith(error.name)) { return error.message; @@ -58,6 +57,7 @@ export default async function runAutorunExecutor( ...(context.cwd ? { cwd: context.cwd } : {}), env, dryRun, + verbose, }); } catch (error) { logger.error(error); diff --git a/packages/nx-plugin/src/internal/command.ts b/packages/nx-plugin/src/internal/command.ts index 34a40ee6c..7d9caa9ae 100644 --- a/packages/nx-plugin/src/internal/command.ts +++ b/packages/nx-plugin/src/internal/command.ts @@ -1,5 +1,5 @@ import ansis from 'ansis'; -import path from 'node:path'; +import * as path from 'node:path'; type ArgumentValue = number | string | boolean | string[] | undefined; export type CliArgsObject> = diff --git a/testing/test-nx-utils/src/lib/utils/nx.ts b/testing/test-nx-utils/src/lib/utils/nx.ts index 33a1ab69f..fecf433fa 100644 --- a/testing/test-nx-utils/src/lib/utils/nx.ts +++ b/testing/test-nx-utils/src/lib/utils/nx.ts @@ -84,7 +84,7 @@ export async function nxShowProjectJson( ) { const { code, stderr, stdout } = await executeProcess({ command: 'npx', - args: ['nx', 'show', `project ${project} --json`], + args: ['nx', 'show', 'project', '--json', project], cwd, }); From fe6d69b8102dce276bb3d99b53059f920de08ab1 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Tue, 9 Sep 2025 00:00:40 +0200 Subject: [PATCH 41/48] refactor: fix int --- packages/nx-plugin/src/executors/cli/executor.int.test.ts | 7 ++++++- packages/nx-plugin/src/executors/cli/executor.ts | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.int.test.ts b/packages/nx-plugin/src/executors/cli/executor.int.test.ts index f8beeb24a..f377a1e3a 100644 --- a/packages/nx-plugin/src/executors/cli/executor.int.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.int.test.ts @@ -45,8 +45,13 @@ describe('runAutorunExecutor', () => { expect(executeProcessSpy).toHaveBeenCalledTimes(1); expect(executeProcessSpy).toHaveBeenCalledWith({ command: 'npx', - args: expect.arrayContaining(['@code-pushup/cli']), + args: expect.arrayContaining([ + '@code-pushup/cli', + '--verbose', + '--no-progress', + ]), cwd: process.cwd(), + verbose: true, observer: { onError: expect.any(Function), onStdout: expect.any(Function), diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index f0b5fe30b..d2de69689 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -55,9 +55,9 @@ export default async function runAutorunExecutor( await executeProcess({ ...createCliCommandObject({ command, args: cliArgumentObject, bin }), ...(context.cwd ? { cwd: context.cwd } : {}), - env, - dryRun, - verbose, + ...(env ? { env } : {}), + ...(dryRun != null ? { dryRun } : {}), + ...(verbose ? { verbose } : {}), }); } catch (error) { logger.error(error); From 57c13c6b3638ae7c2e10f77696f0a002c1c2b25d Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Tue, 9 Sep 2025 00:13:43 +0200 Subject: [PATCH 42/48] refactor: fix int 2 --- packages/ci/src/lib/create-execution-observer.int.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ci/src/lib/create-execution-observer.int.test.ts b/packages/ci/src/lib/create-execution-observer.int.test.ts index 547cceff9..3c9a4c2d6 100644 --- a/packages/ci/src/lib/create-execution-observer.int.test.ts +++ b/packages/ci/src/lib/create-execution-observer.int.test.ts @@ -9,7 +9,7 @@ describe('createExecutionObserver', () => { it('should use execute process and use observer to capture stdout message and stderr will be empty', async () => { const { stdout, stderr } = await executeProcess({ command: 'node', - args: ['-e', `"console.log('${message}');"`], + args: ['-e', `console.log('${message}');`], observer: createExecutionObserver(), }); @@ -20,7 +20,7 @@ describe('createExecutionObserver', () => { it('should use execute process and use observer to capture stdout message and stderr will be error', async () => { const { stdout, stderr } = await executeProcess({ command: 'node', - args: ['-e', `"console.log('${message}'); console.error('${error}');"`], + args: ['-e', `console.log('${message}'); console.error('${error}');`], observer: createExecutionObserver(), }); @@ -31,7 +31,7 @@ describe('createExecutionObserver', () => { it('should use execute process and use observer to capture stderr error and ignore stdout message', async () => { const { stdout, stderr } = await executeProcess({ command: 'node', - args: ['-e', `"console.log('${message}'); console.error('${error}');"`], + args: ['-e', `console.log('${message}'); console.error('${error}');`], observer: createExecutionObserver({ silent: true }), }); From 39c112a48d214d9b042643229beacb2e93489eca Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Tue, 9 Sep 2025 00:26:28 +0200 Subject: [PATCH 43/48] refactor: fix int 3 --- packages/plugin-eslint/src/lib/runner.int.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-eslint/src/lib/runner.int.test.ts b/packages/plugin-eslint/src/lib/runner.int.test.ts index 865724d9f..8cb7c5b48 100644 --- a/packages/plugin-eslint/src/lib/runner.int.test.ts +++ b/packages/plugin-eslint/src/lib/runner.int.test.ts @@ -39,8 +39,8 @@ describe('executeRunner', () => { beforeAll(() => { cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(appDir); - // Windows does not require additional quotation marks for globs - platformSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); + // Use the actual platform for proper glob handling + platformSpy = vi.spyOn(os, 'platform').mockReturnValue(process.platform); }); afterAll(() => { From c8c04d2a5622b78e61f123fae973c4ddcad17759 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Tue, 9 Sep 2025 02:25:55 +0200 Subject: [PATCH 44/48] refactor: revert --- .../artifacts-config/code-pushup.config.ts | 16 ++ .../artifacts-config/eslint.config.cjs | 13 + .../fixtures/artifacts-config/src/index.js | 9 + .../__snapshots__/collect.e2e.test.ts.snap | 232 ++++++++++++++++ .../tests/collect.e2e.test.ts | 28 ++ .../lib/create-execution-observer.int.test.ts | 6 +- packages/nx-plugin/package.json | 3 +- .../nx-plugin/src/executors/cli/README.md | 2 +- .../src/executors/cli/executor.int.test.ts | 50 +--- .../nx-plugin/src/executors/cli/executor.ts | 51 ++-- .../src/executors/cli/executor.unit.test.ts | 27 +- .../nx-plugin/src/executors/cli/schema.json | 7 - .../nx-plugin/src/executors/internal/cli.ts | 43 +-- .../src/executors/internal/cli.unit.test.ts | 105 +++++++- .../nx-plugin/src/executors/internal/types.ts | 14 +- packages/nx-plugin/src/index.ts | 2 +- packages/nx-plugin/src/internal/command.ts | 155 ----------- .../nx-plugin/src/internal/execute-process.ts | 96 +++---- .../src/internal/execute-process.unit.test.ts | 92 +++++++ packages/nx-plugin/src/internal/types.ts | 3 +- .../src/plugin/target/configuration-target.ts | 11 +- .../src/plugin/target/executor-target.ts | 20 +- .../target/executor.target.unit.test.ts | 8 +- .../nx-plugin/src/plugin/target/targets.ts | 10 +- packages/plugin-eslint/README.md | 145 +++++++++- .../plugin-eslint/src/lib/runner.int.test.ts | 4 +- packages/utils/src/index.ts | 12 +- packages/utils/src/lib/command.int.test.ts | 104 -------- packages/utils/src/lib/command.ts | 168 ------------ packages/utils/src/lib/command.unit.test.ts | 248 ------------------ packages/utils/src/lib/execute-process.ts | 69 +++-- packages/utils/src/lib/file-system.ts | 5 + .../utils/src/lib/file-system.unit.test.ts | 10 +- .../src/lib/format-command-log.int.test.ts | 61 +++++ packages/utils/src/lib/format-command-log.ts | 27 ++ packages/utils/src/lib/transform.ts | 62 +++++ packages/utils/src/lib/transform.unit.test.ts | 68 +++++ testing/test-nx-utils/src/lib/utils/nx.ts | 2 +- 38 files changed, 1026 insertions(+), 962 deletions(-) create mode 100644 e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/code-pushup.config.ts create mode 100644 e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/eslint.config.cjs create mode 100644 e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/src/index.js delete mode 100644 packages/nx-plugin/src/internal/command.ts create mode 100644 packages/nx-plugin/src/internal/execute-process.unit.test.ts delete mode 100644 packages/utils/src/lib/command.int.test.ts delete mode 100644 packages/utils/src/lib/command.ts delete mode 100644 packages/utils/src/lib/command.unit.test.ts create mode 100644 packages/utils/src/lib/format-command-log.int.test.ts create mode 100644 packages/utils/src/lib/format-command-log.ts diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/code-pushup.config.ts b/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/code-pushup.config.ts new file mode 100644 index 000000000..3483337ec --- /dev/null +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/code-pushup.config.ts @@ -0,0 +1,16 @@ +import eslintPlugin from '@code-pushup/eslint-plugin'; + +export default { + plugins: [ + await eslintPlugin( + { patterns: ['src/*.js'] }, + { + artifacts: { + generateArtifactsCommand: + 'npx eslint src/*.js --format json --output-file ./.code-pushup/eslint-report.json', + artifactsPaths: ['./.code-pushup/eslint-report.json'], + }, + }, + ), + ], +}; diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/eslint.config.cjs b/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/eslint.config.cjs new file mode 100644 index 000000000..cef1161f4 --- /dev/null +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/eslint.config.cjs @@ -0,0 +1,13 @@ +/** @type {import('eslint').Linter.Config[]} */ +module.exports = [ + { + ignores: ['code-pushup.config.ts'], + }, + { + rules: { + eqeqeq: 'error', + 'max-lines': ['warn', 100], + 'no-unused-vars': 'warn', + }, + }, +]; diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/src/index.js b/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/src/index.js new file mode 100644 index 000000000..39665c6ec --- /dev/null +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/artifacts-config/src/index.js @@ -0,0 +1,9 @@ +function unusedFn() { + return '42'; +} + +module.exports = function orwell() { + if (2 + 2 == 5) { + console.log(1984); + } +}; diff --git a/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index b20625b3b..a3de821a2 100644 --- a/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -225,3 +225,235 @@ exports[`PLUGIN collect report with eslint-plugin NPM package > should run ESLin ], } `; + +exports[`PLUGIN collect report with eslint-plugin NPM package > should run ESLint plugin with artifacts options 1`] = ` +{ + "packageName": "@code-pushup/core", + "plugins": [ + { + "audits": [ + { + "description": "ESLint rule **eqeqeq**.", + "details": { + "issues": [ + { + "message": "Expected '===' and instead saw '=='.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-eslint-e2e/__test__/artifacts-config/src/index.js", + "position": { + "endColumn": 15, + "endLine": 6, + "startColumn": 13, + "startLine": 6, + }, + }, + }, + ], + }, + "displayValue": "1 error", + "docsUrl": "https://eslint.org/docs/latest/rules/eqeqeq", + "score": 0, + "slug": "eqeqeq", + "title": "Require the use of \`===\` and \`!==\`", + "value": 1, + }, + { + "description": "ESLint rule **max-lines**. + +Custom options: + +\`\`\`json +100 +\`\`\`", + "details": { + "issues": [], + }, + "displayValue": "passed", + "docsUrl": "https://eslint.org/docs/latest/rules/max-lines", + "score": 1, + "slug": "max-lines-71b54366cb01f77b", + "title": "Enforce a maximum number of lines per file", + "value": 0, + }, + { + "description": "ESLint rule **no-unused-vars**.", + "details": { + "issues": [ + { + "message": "'unusedFn' is defined but never used.", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-eslint-e2e/__test__/artifacts-config/src/index.js", + "position": { + "endColumn": 18, + "endLine": 1, + "startColumn": 10, + "startLine": 1, + }, + }, + }, + ], + }, + "displayValue": "1 warning", + "docsUrl": "https://eslint.org/docs/latest/rules/no-unused-vars", + "score": 0, + "slug": "no-unused-vars", + "title": "Disallow unused variables", + "value": 1, + }, + ], + "description": "Official Code PushUp ESLint plugin", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/eslint-plugin", + "groups": [ + { + "description": "Code that either will cause an error or may cause confusing behavior. Developers should consider this a high priority to resolve.", + "refs": [ + { + "slug": "no-unused-vars", + "weight": 1, + }, + ], + "slug": "problems", + "title": "Problems", + }, + { + "description": "Something that could be done in a better way but no errors will occur if the code isn't changed.", + "refs": [ + { + "slug": "eqeqeq", + "weight": 1, + }, + { + "slug": "max-lines-71b54366cb01f77b", + "weight": 1, + }, + ], + "slug": "suggestions", + "title": "Suggestions", + }, + ], + "icon": "eslint", + "packageName": "@code-pushup/eslint-plugin", + "slug": "eslint", + "title": "ESLint", + }, + ], +} +`; + +exports[`PLUGIN collect report with eslint-plugin NPM package > should run ESLint plugin with artifacts options and create eslint-report.json and report.json 1`] = ` +{ + "packageName": "@code-pushup/core", + "plugins": [ + { + "audits": [ + { + "description": "ESLint rule **eqeqeq**.", + "details": { + "issues": [ + { + "message": "Expected '===' and instead saw '=='.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-eslint-e2e/__test__/artifacts-config/src/index.js", + "position": { + "endColumn": 15, + "endLine": 6, + "startColumn": 13, + "startLine": 6, + }, + }, + }, + ], + }, + "displayValue": "1 error", + "docsUrl": "https://eslint.org/docs/latest/rules/eqeqeq", + "score": 0, + "slug": "eqeqeq", + "title": "Require the use of \`===\` and \`!==\`", + "value": 1, + }, + { + "description": "ESLint rule **max-lines**. + +Custom options: + +\`\`\`json +100 +\`\`\`", + "details": { + "issues": [], + }, + "displayValue": "passed", + "docsUrl": "https://eslint.org/docs/latest/rules/max-lines", + "score": 1, + "slug": "max-lines-71b54366cb01f77b", + "title": "Enforce a maximum number of lines per file", + "value": 0, + }, + { + "description": "ESLint rule **no-unused-vars**.", + "details": { + "issues": [ + { + "message": "'unusedFn' is defined but never used.", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-eslint-e2e/__test__/artifacts-config/src/index.js", + "position": { + "endColumn": 18, + "endLine": 1, + "startColumn": 10, + "startLine": 1, + }, + }, + }, + ], + }, + "displayValue": "1 warning", + "docsUrl": "https://eslint.org/docs/latest/rules/no-unused-vars", + "score": 0, + "slug": "no-unused-vars", + "title": "Disallow unused variables", + "value": 1, + }, + ], + "description": "Official Code PushUp ESLint plugin", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/eslint-plugin", + "groups": [ + { + "description": "Code that either will cause an error or may cause confusing behavior. Developers should consider this a high priority to resolve.", + "refs": [ + { + "slug": "no-unused-vars", + "weight": 1, + }, + ], + "slug": "problems", + "title": "Problems", + }, + { + "description": "Something that could be done in a better way but no errors will occur if the code isn't changed.", + "refs": [ + { + "slug": "eqeqeq", + "weight": 1, + }, + { + "slug": "max-lines-71b54366cb01f77b", + "weight": 1, + }, + ], + "slug": "suggestions", + "title": "Suggestions", + }, + ], + "icon": "eslint", + "packageName": "@code-pushup/eslint-plugin", + "slug": "eslint", + "title": "ESLint", + }, + ], +} +`; diff --git a/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts b/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts index 98966c6b8..bbfada16a 100644 --- a/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts +++ b/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts @@ -20,6 +20,7 @@ describe('PLUGIN collect report with eslint-plugin NPM package', () => { ); const fixturesFlatConfigDir = path.join(fixturesDir, 'flat-config'); const fixturesLegacyConfigDir = path.join(fixturesDir, 'legacy-config'); + const fixturesArtifactsConfigDir = path.join(fixturesDir, 'artifacts-config'); const envRoot = path.join( E2E_ENVIRONMENTS_DIR, @@ -28,22 +29,32 @@ describe('PLUGIN collect report with eslint-plugin NPM package', () => { ); const flatConfigDir = path.join(envRoot, 'flat-config'); const legacyConfigDir = path.join(envRoot, 'legacy-config'); + const artifactsConfigDir = path.join(envRoot, 'artifacts-config'); const flatConfigOutputDir = path.join(flatConfigDir, '.code-pushup'); const legacyConfigOutputDir = path.join(legacyConfigDir, '.code-pushup'); + const artifactsConfigOutputDir = path.join( + artifactsConfigDir, + '.code-pushup', + ); beforeAll(async () => { await cp(fixturesFlatConfigDir, flatConfigDir, { recursive: true }); await cp(fixturesLegacyConfigDir, legacyConfigDir, { recursive: true }); + await cp(fixturesArtifactsConfigDir, artifactsConfigDir, { + recursive: true, + }); }); afterAll(async () => { await teardownTestFolder(flatConfigDir); await teardownTestFolder(legacyConfigDir); + await teardownTestFolder(artifactsConfigDir); }); afterEach(async () => { await teardownTestFolder(flatConfigOutputDir); await teardownTestFolder(legacyConfigOutputDir); + await teardownTestFolder(artifactsConfigOutputDir); }); it('should run ESLint plugin for flat config and create report.json', async () => { @@ -80,4 +91,21 @@ describe('PLUGIN collect report with eslint-plugin NPM package', () => { expect(() => reportSchema.parse(report)).not.toThrow(); expect(omitVariableReportData(report as Report)).toMatchSnapshot(); }); + + it('should run ESLint plugin with artifacts options and create eslint-report.json and report.json', async () => { + const { code } = await executeProcess({ + command: 'npx', + args: ['@code-pushup/cli', 'collect', '--no-progress'], + cwd: artifactsConfigDir, + }); + + expect(code).toBe(0); + + const report = await readJsonFile( + path.join(artifactsConfigOutputDir, 'report.json'), + ); + + expect(() => reportSchema.parse(report)).not.toThrow(); + expect(omitVariableReportData(report as Report)).toMatchSnapshot(); + }); }); diff --git a/packages/ci/src/lib/create-execution-observer.int.test.ts b/packages/ci/src/lib/create-execution-observer.int.test.ts index 3c9a4c2d6..547cceff9 100644 --- a/packages/ci/src/lib/create-execution-observer.int.test.ts +++ b/packages/ci/src/lib/create-execution-observer.int.test.ts @@ -9,7 +9,7 @@ describe('createExecutionObserver', () => { it('should use execute process and use observer to capture stdout message and stderr will be empty', async () => { const { stdout, stderr } = await executeProcess({ command: 'node', - args: ['-e', `console.log('${message}');`], + args: ['-e', `"console.log('${message}');"`], observer: createExecutionObserver(), }); @@ -20,7 +20,7 @@ describe('createExecutionObserver', () => { it('should use execute process and use observer to capture stdout message and stderr will be error', async () => { const { stdout, stderr } = await executeProcess({ command: 'node', - args: ['-e', `console.log('${message}'); console.error('${error}');`], + args: ['-e', `"console.log('${message}'); console.error('${error}');"`], observer: createExecutionObserver(), }); @@ -31,7 +31,7 @@ describe('createExecutionObserver', () => { it('should use execute process and use observer to capture stderr error and ignore stdout message', async () => { const { stdout, stderr } = await executeProcess({ command: 'node', - args: ['-e', `console.log('${message}'); console.error('${error}');`], + args: ['-e', `"console.log('${message}'); console.error('${error}');"`], observer: createExecutionObserver({ silent: true }), }); diff --git a/packages/nx-plugin/package.json b/packages/nx-plugin/package.json index 74ff721ac..f5b779192 100644 --- a/packages/nx-plugin/package.json +++ b/packages/nx-plugin/package.json @@ -37,8 +37,7 @@ "@nx/devkit": ">=17.0.0", "ansis": "^3.3.0", "nx": ">=17.0.0", - "zod": "^4.0.5", - "chalk": "5.3.0" + "zod": "^4.0.5" }, "files": [ "src", diff --git a/packages/nx-plugin/src/executors/cli/README.md b/packages/nx-plugin/src/executors/cli/README.md index 85d7a9747..fdb01c9f7 100644 --- a/packages/nx-plugin/src/executors/cli/README.md +++ b/packages/nx-plugin/src/executors/cli/README.md @@ -72,6 +72,6 @@ Show what will be executed without actually executing it: | ----------------- | --------- | ------------------------------------------------------------------ | | **projectPrefix** | `string` | prefix for upload.project on non root projects | | **dryRun** | `boolean` | To debug the executor, dry run the command without real execution. | -| **cliBin** | `string` | Path to Code PushUp CLI | +| **bin** | `string` | Path to Code PushUp CLI | For all other options see the [CLI autorun documentation](../../../../cli/README.md#autorun-command). diff --git a/packages/nx-plugin/src/executors/cli/executor.int.test.ts b/packages/nx-plugin/src/executors/cli/executor.int.test.ts index f377a1e3a..740486f33 100644 --- a/packages/nx-plugin/src/executors/cli/executor.int.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.int.test.ts @@ -45,60 +45,12 @@ describe('runAutorunExecutor', () => { expect(executeProcessSpy).toHaveBeenCalledTimes(1); expect(executeProcessSpy).toHaveBeenCalledWith({ command: 'npx', - args: expect.arrayContaining([ - '@code-pushup/cli', - '--verbose', - '--no-progress', - ]), + args: expect.arrayContaining(['@code-pushup/cli']), cwd: process.cwd(), - verbose: true, observer: { onError: expect.any(Function), onStdout: expect.any(Function), }, }); }); - - it('should execute command with provided bin', async () => { - const bin = 'packages/cli/dist'; - const output = await runAutorunExecutor( - { - verbose: true, - bin, - }, - executorContext('utils'), - ); - expect(output.success).toBe(true); - - expect(executeProcessSpy).toHaveBeenCalledTimes(1); - expect(executeProcessSpy).toHaveBeenCalledWith( - expect.objectContaining({ - args: expect.arrayContaining([bin]), - }), - ); - }); - - it('should execute command with provided env vars', async () => { - const output = await runAutorunExecutor( - { - verbose: true, - env: { - NODE_OPTIONS: '--import tsx', - TSX_TSCONFIG_PATH: 'tsconfig.base.json', - }, - }, - executorContext('utils'), - ); - expect(output.success).toBe(true); - - expect(executeProcessSpy).toHaveBeenCalledTimes(1); - expect(executeProcessSpy).toHaveBeenCalledWith( - expect.objectContaining({ - env: { - NODE_OPTIONS: '--import tsx', - TSX_TSCONFIG_PATH: 'tsconfig.base.json', - }, - }), - ); - }); }); diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index d2de69689..7eece1129 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -8,19 +8,6 @@ import { normalizeContext } from '../internal/context.js'; import type { AutorunCommandExecutorOptions } from './schema.js'; import { mergeExecutorOptions, parseAutorunExecutorOptions } from './utils.js'; -export function stringifyError(error: unknown): string { - if (error instanceof Error) { - if (error.name === 'Error' || error.message.startsWith(error.name)) { - return error.message; - } - return `${error.name}: ${error.message}`; - } - if (typeof error === 'string') { - return error; - } - return JSON.stringify(error); -} - export type ExecutorOutput = { success: boolean; command?: string; @@ -32,7 +19,7 @@ export default async function runAutorunExecutor( context: ExecutorContext, ): Promise { const normalizedContext = normalizeContext(context); - const { env, bin, ...mergedOptions } = mergeExecutorOptions( + const mergedOptions = mergeExecutorOptions( context.target?.options, terminalAndExecutorOptions, ); @@ -43,31 +30,29 @@ export default async function runAutorunExecutor( const { dryRun, verbose, command } = mergedOptions; const commandString = createCliCommandString({ command, - bin, args: cliArgumentObject, }); - if (verbose) { logger.info(`Run CLI executor ${command ?? ''}`); + logger.info(`Command: ${commandString}`); } - - try { - await executeProcess({ - ...createCliCommandObject({ command, args: cliArgumentObject, bin }), - ...(context.cwd ? { cwd: context.cwd } : {}), - ...(env ? { env } : {}), - ...(dryRun != null ? { dryRun } : {}), - ...(verbose ? { verbose } : {}), - }); - } catch (error) { - logger.error(error); - return { - success: false, - command: commandString, - error: error instanceof Error ? error : new Error(stringifyError(error)), - }; + if (dryRun) { + logger.warn(`DryRun execution of: ${commandString}`); + } else { + try { + await executeProcess({ + ...createCliCommandObject({ command, args: cliArgumentObject }), + ...(context.cwd ? { cwd: context.cwd } : {}), + }); + } catch (error) { + logger.error(error); + return { + success: false, + command: commandString, + error: error as Error, + }; + } } - return { success: true, command: commandString, diff --git a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts index 00150a71c..daab1594c 100644 --- a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts @@ -1,7 +1,7 @@ import { logger } from '@nx/devkit'; import { afterAll, afterEach, beforeEach, expect, vi } from 'vitest'; import { executorContext } from '@code-pushup/test-nx-utils'; -import { MEMFS_VOLUME, removeColorCodes } from '@code-pushup/test-utils'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import * as executeProcessModule from '../../internal/execute-process.js'; import runAutorunExecutor from './executor.js'; @@ -124,25 +124,24 @@ describe('runAutorunExecutor', () => { expect(output.command).toMatch('--verbose'); expect(loggerWarnSpy).toHaveBeenCalledTimes(0); - expect(loggerInfoSpy).toHaveBeenCalledTimes(1); + expect(loggerInfoSpy).toHaveBeenCalledTimes(2); expect(loggerInfoSpy).toHaveBeenCalledWith( expect.stringContaining(`Run CLI executor`), ); + expect(loggerInfoSpy).toHaveBeenCalledWith( + expect.stringContaining('Command: npx @code-pushup/cli'), + ); }); - it('should call executeProcess with dryRun option', async () => { - const output = await runAutorunExecutor( - { dryRun: true }, - executorContext('utils'), - ); + it('should log command if dryRun is set', async () => { + await runAutorunExecutor({ dryRun: true }, executorContext('utils')); - expect(output.success).toBe(true); - expect(executeProcessSpy).toHaveBeenCalledWith( - expect.objectContaining({ - dryRun: true, - }), - ); expect(loggerInfoSpy).toHaveBeenCalledTimes(0); - expect(loggerWarnSpy).toHaveBeenCalledTimes(0); + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'DryRun execution of: npx @code-pushup/cli --dryRun', + ), + ); }); }); diff --git a/packages/nx-plugin/src/executors/cli/schema.json b/packages/nx-plugin/src/executors/cli/schema.json index 983dd069f..85cd0de19 100644 --- a/packages/nx-plugin/src/executors/cli/schema.json +++ b/packages/nx-plugin/src/executors/cli/schema.json @@ -21,13 +21,6 @@ "type": "string", "description": "Path to Code PushUp CLI" }, - "env": { - "type": "object", - "description": "Environment variables to set when running the command", - "additionalProperties": { - "type": "string" - } - }, "verbose": { "type": "boolean", "description": "Print additional logs" diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts index b348df1b5..bab74a8f1 100644 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ b/packages/nx-plugin/src/executors/internal/cli.ts @@ -1,5 +1,4 @@ import { logger } from '@nx/devkit'; -import chalk from 'chalk'; import type { ProcessConfig } from '../../internal/execute-process.js'; export function createCliCommandString(options?: { @@ -13,49 +12,15 @@ export function createCliCommandString(options?: { )}`; } -export function formatCommandLog({ - command, - args = [], - env, -}: { - command: string; - args: string[]; - env?: Record; -}): string { - const logElements: string[] = []; - if (env) { - const envVars = Object.entries(env).map( - ([key, value]) => - `${chalk.green(key)}="${chalk.blueBright(value.replaceAll('"', ''))}"`, - ); - // eslint-disable-next-line functional/immutable-data - logElements.push(...envVars); - } - // eslint-disable-next-line functional/immutable-data - logElements.push(chalk.cyan(command)); - if (args.length > 0) { - // eslint-disable-next-line functional/immutable-data - logElements.push(chalk.dim.gray(args.join(' '))); - } - return logElements.join(' '); -} - export function createCliCommandObject(options?: { args?: Record; - command?: 'autorun' | 'collect' | 'upload' | 'print-config' | string; + command?: string; bin?: string; }): ProcessConfig { - const { bin = 'npx @code-pushup/cli', command, args } = options ?? {}; - const binArr = bin.split(' '); - - // If bin contains spaces, use the first part as command and rest as args - // If bin is a single path, default to 'npx' and use the bin as first arg - const finalCommand = binArr.length > 1 ? (binArr[0] ?? 'npx') : 'npx'; - const binArgs = binArr.length > 1 ? binArr.slice(1) : [bin]; - + const { bin = '@code-pushup/cli', command, args } = options ?? {}; return { - command: finalCommand, - args: [...binArgs, ...objectToCliArgs({ _: command ?? [], ...args })], + command: 'npx', + args: [bin, ...objectToCliArgs({ _: command ?? [], ...args })], observer: { onError: error => { logger.error(error.message); diff --git a/packages/nx-plugin/src/executors/internal/cli.unit.test.ts b/packages/nx-plugin/src/executors/internal/cli.unit.test.ts index b889a0bd0..b8251484c 100644 --- a/packages/nx-plugin/src/executors/internal/cli.unit.test.ts +++ b/packages/nx-plugin/src/executors/internal/cli.unit.test.ts @@ -1,5 +1,108 @@ import { describe, expect, it } from 'vitest'; -import { createCliCommandObject } from './cli.js'; +import { + createCliCommandObject, + createCliCommandString, + objectToCliArgs, +} from './cli.js'; + +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 nested objects', () => { + const params = { persist: { format: ['json', 'md'], verbose: false } }; + const result = objectToCliArgs(params); + expect(result).toEqual([ + '--persist.format="json"', + '--persist.format="md"', + '--no-persist.verbose', + ]); + }); + + 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('createCliCommandString', () => { + it('should create command out of object for arguments', () => { + const result = createCliCommandString({ args: { verbose: true } }); + expect(result).toBe('npx @code-pushup/cli --verbose'); + }); + + it('should create command out of object for arguments with positional', () => { + const result = createCliCommandString({ + args: { _: 'autorun', verbose: true }, + }); + expect(result).toBe('npx @code-pushup/cli autorun --verbose'); + }); +}); describe('createCliCommandObject', () => { it('should create command out of object for arguments', () => { diff --git a/packages/nx-plugin/src/executors/internal/types.ts b/packages/nx-plugin/src/executors/internal/types.ts index 03f5f44d1..2f529038a 100644 --- a/packages/nx-plugin/src/executors/internal/types.ts +++ b/packages/nx-plugin/src/executors/internal/types.ts @@ -15,17 +15,21 @@ export type GeneralExecutorOnlyOptions = { export type ProjectExecutorOnlyOptions = { projectPrefix?: string; }; -type LooseAutocomplete = T | (string & {}); + /** * CLI types that apply globally for all commands. */ -export type Command = LooseAutocomplete< - 'collect' | 'upload' | 'autorun' | 'print-config' | 'compare' | 'merge-diffs' ->; +export type Command = + | 'collect' + | 'upload' + | 'autorun' + | 'print-config' + | 'compare' + | 'merge-diffs' + | 'history'; export type GlobalExecutorOptions = { command?: Command; bin?: string; - env?: Record; verbose?: boolean; progress?: boolean; config?: string; diff --git a/packages/nx-plugin/src/index.ts b/packages/nx-plugin/src/index.ts index 7571e7f53..356be758c 100644 --- a/packages/nx-plugin/src/index.ts +++ b/packages/nx-plugin/src/index.ts @@ -11,7 +11,7 @@ const plugin = { export default plugin; export type { AutorunCommandExecutorOptions } from './executors/cli/schema.js'; -export { objectToCliArgs } from './internal/command.js'; +export { objectToCliArgs } from './executors/internal/cli.js'; export { generateCodePushupConfig } from './generators/configuration/code-pushup-config.js'; export { configurationGenerator } from './generators/configuration/generator.js'; export type { ConfigurationGeneratorOptions } from './generators/configuration/schema.js'; diff --git a/packages/nx-plugin/src/internal/command.ts b/packages/nx-plugin/src/internal/command.ts deleted file mode 100644 index 7d9caa9ae..000000000 --- a/packages/nx-plugin/src/internal/command.ts +++ /dev/null @@ -1,155 +0,0 @@ -import ansis from 'ansis'; -import * as path from 'node:path'; - -type ArgumentValue = number | string | boolean | string[] | undefined; -export type CliArgsObject> = - T extends never - ? Record | { _: string } - : T; - -/** - * Escapes command line arguments that contain spaces, quotes, or other special characters. - * - * @param {string[]} args - Array of command arguments to escape. - * @returns {string[]} - Array of escaped arguments suitable for shell execution. - */ -export function escapeCliArgs(args: string[]): string[] { - return args.map(arg => { - if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { - return `"${arg.replace(/"/g, '\\"')}"`; - } - return arg; - }); -} - -/** - * Formats environment variable values for display by stripping quotes and then escaping. - * - * @param {string} value - Environment variable value to format. - * @returns {string} - Formatted and escaped value suitable for display. - */ -export function formatEnvValue(value: string): string { - // Strip quotes from the value for display - const cleanValue = value.replace(/"/g, ''); - return escapeCliArgs([cleanValue])[0] ?? cleanValue; -} - -/** - * Builds a command string by escaping arguments that contain spaces, quotes, or other special characters. - * - * @param {string} command - The base command to execute. - * @param {string[]} args - Array of command arguments. - * @returns {string} - The complete command string with properly escaped arguments. - */ -export function buildCommandString( - command: string, - args: string[] = [], -): string { - if (args.length === 0) { - return command; - } - - return `${command} ${escapeCliArgs(args).join(' ')}`; -} - -/** - * Options for formatting a command log. - */ -export interface FormatCommandLogOptions { - command: string; - args?: string[]; - cwd?: string; - env?: Record; -} - -/** - * Formats a command string with optional cwd prefix, environment variables, and ANSI colors. - * - * @param {FormatCommandLogOptions} options - Command formatting options. - * @returns {string} - ANSI-colored formatted command string. - */ -export function formatCommandLog(options: FormatCommandLogOptions): string { - const { command, args = [], cwd = process.cwd(), env } = options; - const relativeDir = path.relative(process.cwd(), cwd); - - return [ - ...(relativeDir && relativeDir !== '.' - ? [ansis.italic(ansis.gray(relativeDir))] - : []), - ansis.yellow('$'), - ...(env && Object.keys(env).length > 0 - ? Object.entries(env).map(([key, value]) => { - return ansis.gray(`${key}=${formatEnvValue(value)}`); - }) - : []), - ansis.gray(command), - ansis.gray(escapeCliArgs(args).join(' ')), - ].join(' '); -} - -/** - * Converts an object with different types of values into an array of command-line arguments. - * - * @example - * const args = objectToCliArgs({ - * _: ['node', 'index.js'], // node index.js - * name: 'Juanita', // --name=Juanita - * formats: ['json', 'md'] // --format=json --format=md - * }); - */ -export function objectToCliArgs< - T extends object = Record, ->(params?: CliArgsObject): string[] { - if (!params) { - return []; - } - - return Object.entries(params).flatMap(([key, value]) => { - // process/file/script - if (key === '_') { - return Array.isArray(value) ? value : [`${value}`]; - } - const prefix = key.length === 1 ? '-' : '--'; - // "-*" arguments (shorthands) - if (Array.isArray(value)) { - return value.map(v => `${prefix}${key}="${v}"`); - } - // "--*" arguments ========== - - if (typeof value === 'object') { - return Object.entries(value as Record).flatMap( - // transform nested objects to the dot notation `key.subkey` - ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), - ); - } - - 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 (typeof value === 'undefined') { - return []; - } - - throw new Error(`Unsupported type ${typeof value} for key ${key}`); - }); -} - -/** - * Converts a file path to a CLI argument by wrapping it in quotes to handle spaces. - * - * @param {string} filePath - The file path to convert to a CLI argument. - * @returns {string} - The quoted file path suitable for CLI usage. - */ -export function filePathToCliArg(filePath: string): string { - // needs to be escaped if spaces included - return `"${filePath}"`; -} diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index 53584d110..cf61f3e84 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -1,10 +1,10 @@ -/* - * COPY OF @code-pusup/utils - * This is needed until dual-build is set up for all packages - */ -import { logger } from '@nx/devkit'; -import { type ChildProcess, exec } from 'node:child_process'; -import { buildCommandString, formatCommandLog } from './command.js'; +import { gray } from 'ansis'; +import { spawn } from 'node:child_process'; +import { ui } from '@code-pushup/utils'; + +export function calcDuration(start: number, stop?: number): number { + return Math.round((stop ?? performance.now()) - start); +} /** * Represents the process result. @@ -18,6 +18,8 @@ export type ProcessResult = { stdout: string; stderr: string; code: number | null; + date: string; + duration: number; }; /** @@ -84,7 +86,6 @@ export type ProcessConfig = { command: string; args?: string[]; cwd?: string; - env?: Record; observer?: ProcessObserver; ignoreExitCode?: boolean; }; @@ -99,12 +100,11 @@ export type ProcessConfig = { * * @example * const observer = { - * onStdout: (stdout, childProcess) => console.info(stdout) + * onStdout: (stdout) => console.info(stdout) * } */ export type ProcessObserver = { - onStdout?: (stdout: string, childProcess: ChildProcess) => void; - onStderr?: (stderr: string, childProcess: ChildProcess) => void; + onStdout?: (stdout: string) => void; onError?: (error: ProcessError) => void; onComplete?: () => void; }; @@ -125,7 +125,7 @@ export type ProcessObserver = { * // async process execution * const result = await executeProcess({ * command: 'node', - * args: ['download-data.js'], + * args: ['download-data'], * observer: { * onStdout: updateProgress, * error: handleError, @@ -137,69 +137,47 @@ export type ProcessObserver = { * * @param cfg - see {@link ProcessConfig} */ -export function executeProcess( - cfg: ProcessConfig & { verbose?: boolean; dryRun?: boolean }, -): Promise { - const { - observer, - cwd, - command, - args, - ignoreExitCode = false, - env, - verbose, - } = cfg; - const { onStdout, onStderr, onError, onComplete } = observer ?? {}; +export function executeProcess(cfg: ProcessConfig): Promise { + const { observer, cwd, command, args, ignoreExitCode = false } = cfg; + const { onStdout, onError, onComplete } = observer ?? {}; + const date = new Date().toISOString(); + const start = performance.now(); - if (verbose) { - logger.log( - formatCommandLog({ - command, - args, - cwd: cfg.cwd ?? process.cwd(), - env, - }), - ); - } + const logCommand = [command, ...(args || [])].join(' '); + ui().logger.log( + gray( + `Executing command:\n${logCommand}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, + ), + ); return new Promise((resolve, reject) => { - const commandString = buildCommandString(command, args ?? []); - - const childProcess = exec(commandString, { - cwd, - env: env ? { ...process.env, ...env } : process.env, - maxBuffer: 1024 * 1000000, // 1GB buffer like nx:run-commands - windowsHide: false, - }); + // shell:true tells Windows to use shell command for spawning a child process + const process = spawn(command, args, { cwd, shell: true }); // eslint-disable-next-line functional/no-let let stdout = ''; // eslint-disable-next-line functional/no-let let stderr = ''; - if (childProcess.stdout) { - childProcess.stdout.on('data', data => { - stdout += String(data); - onStdout?.(String(data), childProcess); - }); - } + process.stdout.on('data', data => { + stdout += String(data); + onStdout?.(String(data)); + }); - if (childProcess.stderr) { - childProcess.stderr.on('data', data => { - stderr += String(data); - onStderr?.(String(data), childProcess); - }); - } + process.stderr.on('data', data => { + stderr += String(data); + }); - childProcess.on('error', err => { + process.on('error', err => { stderr += err.toString(); }); - childProcess.on('close', code => { + process.on('close', code => { + const timings = { date, duration: calcDuration(start) }; if (code === 0 || ignoreExitCode) { onComplete?.(); - resolve({ code, stdout, stderr }); + resolve({ code, stdout, stderr, ...timings }); } else { - const errorMsg = new ProcessError({ code, stdout, stderr }); + const errorMsg = new ProcessError({ code, stdout, stderr, ...timings }); onError?.(errorMsg); reject(errorMsg); } diff --git a/packages/nx-plugin/src/internal/execute-process.unit.test.ts b/packages/nx-plugin/src/internal/execute-process.unit.test.ts new file mode 100644 index 000000000..5893b867f --- /dev/null +++ b/packages/nx-plugin/src/internal/execute-process.unit.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getAsyncProcessRunnerConfig } from '@code-pushup/test-utils'; +import { type ProcessObserver, executeProcess } from './execute-process.js'; + +describe('executeProcess', () => { + const spyObserver: ProcessObserver = { + onStdout: vi.fn(), + onError: vi.fn(), + onComplete: vi.fn(), + }; + const errorSpy = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should work with node command `node -v`', async () => { + const processResult = await executeProcess({ + command: `node`, + args: ['-v'], + observer: spyObserver, + }); + + // Note: called once or twice depending on environment (2nd time for a new line) + expect(spyObserver.onStdout).toHaveBeenCalled(); + expect(spyObserver.onComplete).toHaveBeenCalledOnce(); + expect(spyObserver.onError).not.toHaveBeenCalled(); + expect(processResult.stdout).toMatch(/v\d{1,2}(\.\d{1,2}){0,2}/); + }); + + it('should work with npx command `npx --help`', async () => { + const processResult = await executeProcess({ + command: `npx`, + args: ['--help'], + observer: spyObserver, + }); + expect(spyObserver.onStdout).toHaveBeenCalledOnce(); + expect(spyObserver.onComplete).toHaveBeenCalledOnce(); + expect(spyObserver.onError).not.toHaveBeenCalled(); + expect(processResult.stdout).toContain('npm exec'); + }); + + it('should work with script `node custom-script.js`', async () => { + const processResult = await executeProcess({ + ...getAsyncProcessRunnerConfig({ interval: 10, runs: 4 }), + observer: spyObserver, + }).catch(errorSpy); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(processResult.stdout).toContain('process:complete'); + expect(spyObserver.onStdout).toHaveBeenCalledTimes(6); // intro + 4 runs + complete + expect(spyObserver.onError).not.toHaveBeenCalled(); + expect(spyObserver.onComplete).toHaveBeenCalledOnce(); + }); + + it('should work with async script `node custom-script.js` that throws an error', async () => { + const processResult = await executeProcess({ + ...getAsyncProcessRunnerConfig({ + interval: 10, + runs: 1, + throwError: true, + }), + observer: spyObserver, + }).catch(errorSpy); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(processResult).toBeUndefined(); + expect(spyObserver.onStdout).toHaveBeenCalledTimes(2); // intro + 1 run before error + expect(spyObserver.onError).toHaveBeenCalledOnce(); + expect(spyObserver.onComplete).not.toHaveBeenCalled(); + }); + + it('should successfully exit process after an error is thrown when ignoreExitCode is set', async () => { + const processResult = await executeProcess({ + ...getAsyncProcessRunnerConfig({ + interval: 10, + runs: 1, + throwError: true, + }), + observer: spyObserver, + ignoreExitCode: true, + }).catch(errorSpy); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(processResult.code).toBe(1); + expect(processResult.stdout).toContain('process:update'); + expect(processResult.stderr).toContain('dummy-error'); + expect(spyObserver.onStdout).toHaveBeenCalledTimes(2); // intro + 1 run before error + expect(spyObserver.onError).not.toHaveBeenCalled(); + expect(spyObserver.onComplete).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/nx-plugin/src/internal/types.ts b/packages/nx-plugin/src/internal/types.ts index fed464dda..bf3a2d047 100644 --- a/packages/nx-plugin/src/internal/types.ts +++ b/packages/nx-plugin/src/internal/types.ts @@ -1,5 +1,4 @@ export type DynamicTargetOptions = { targetName?: string; - cliBin?: string; - env?: Record; + bin?: string; }; diff --git a/packages/nx-plugin/src/plugin/target/configuration-target.ts b/packages/nx-plugin/src/plugin/target/configuration-target.ts index 9868abfa2..d19b9325b 100644 --- a/packages/nx-plugin/src/plugin/target/configuration-target.ts +++ b/packages/nx-plugin/src/plugin/target/configuration-target.ts @@ -1,16 +1,21 @@ import type { TargetConfiguration } from '@nx/devkit'; import type { RunCommandsOptions } from 'nx/src/executors/run-commands/run-commands.impl'; -import { objectToCliArgs } from '../../internal/command.js'; +import { objectToCliArgs } from '../../executors/internal/cli.js'; import { PACKAGE_NAME } from '../../internal/constants.js'; import { CP_TARGET_NAME } from '../constants.js'; export function createConfigurationTarget(options?: { targetName?: string; projectName?: string; + bin?: string; }): TargetConfiguration { - const { projectName, targetName = CP_TARGET_NAME } = options ?? {}; + const { + projectName, + bin = PACKAGE_NAME, + targetName = CP_TARGET_NAME, + } = options ?? {}; return { - command: `nx g ${PACKAGE_NAME}:configuration ${objectToCliArgs({ + command: `nx g ${bin}:configuration ${objectToCliArgs({ skipTarget: true, targetName, ...(projectName ? { project: projectName } : {}), diff --git a/packages/nx-plugin/src/plugin/target/executor-target.ts b/packages/nx-plugin/src/plugin/target/executor-target.ts index 77dda510f..e8b52eb8f 100644 --- a/packages/nx-plugin/src/plugin/target/executor-target.ts +++ b/packages/nx-plugin/src/plugin/target/executor-target.ts @@ -1,20 +1,18 @@ import type { TargetConfiguration } from '@nx/devkit'; -import type { AutorunCommandExecutorOptions } from '../../executors/cli/schema.js'; import { PACKAGE_NAME } from '../../internal/constants.js'; -import type { CreateNodesOptions } from '../types.js'; +import type { ProjectPrefixOptions } from '../types.js'; -export function createExecutorTarget( - options?: CreateNodesOptions, -): TargetConfiguration { - const { projectPrefix, cliBin, env } = options ?? {}; +export function createExecutorTarget(options?: { + bin?: string; + projectPrefix?: string; +}): TargetConfiguration { + const { bin = PACKAGE_NAME, projectPrefix } = options ?? {}; return { - executor: `${PACKAGE_NAME}:cli`, - ...(cliBin || projectPrefix || env + executor: `${bin}:cli`, + ...(projectPrefix ? { options: { - ...(cliBin ? { bin: cliBin } : {}), - ...(projectPrefix ? { projectPrefix } : {}), - ...(env ? { env } : {}), + projectPrefix, }, } : {}), diff --git a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts index c76b95b18..610b44bd7 100644 --- a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts +++ b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts @@ -2,12 +2,18 @@ import { expect } from 'vitest'; import { createExecutorTarget } from './executor-target.js'; describe('createExecutorTarget', () => { - it('should return executor target with default package name', () => { + it('should return executor target without project name', () => { expect(createExecutorTarget()).toStrictEqual({ executor: '@code-pushup/nx-plugin:cli', }); }); + it('should use bin if provides', () => { + expect(createExecutorTarget({ bin: 'xyz' })).toStrictEqual({ + executor: 'xyz:cli', + }); + }); + it('should use projectPrefix if provided', () => { expect(createExecutorTarget({ projectPrefix: 'cli' })).toStrictEqual({ executor: '@code-pushup/nx-plugin:cli', diff --git a/packages/nx-plugin/src/plugin/target/targets.ts b/packages/nx-plugin/src/plugin/target/targets.ts index 2d4662c60..eb68740ef 100644 --- a/packages/nx-plugin/src/plugin/target/targets.ts +++ b/packages/nx-plugin/src/plugin/target/targets.ts @@ -17,24 +17,20 @@ export type CreateTargetsOptions = { export async function createTargets(normalizedContext: CreateTargetsOptions) { const { targetName = CP_TARGET_NAME, + bin, projectPrefix, - env, - cliBin, } = normalizedContext.createOptions; const rootFiles = await readdir(normalizedContext.projectRoot); return rootFiles.some(filename => filename.match(CODE_PUSHUP_CONFIG_REGEX)) ? { - [targetName]: createExecutorTarget({ - projectPrefix, - env, - cliBin, - }), + [targetName]: createExecutorTarget({ bin, projectPrefix }), } : // if NO code-pushup.config.*.(ts|js|mjs) is present return configuration target { [`${targetName}--configuration`]: createConfigurationTarget({ targetName, projectName: normalizedContext.projectJson.name, + bin, }), }; } diff --git a/packages/plugin-eslint/README.md b/packages/plugin-eslint/README.md index 75a45dc63..a1ace02ff 100644 --- a/packages/plugin-eslint/README.md +++ b/packages/plugin-eslint/README.md @@ -226,6 +226,149 @@ export default { 2. Run the CLI with `npx code-pushup collect` and view or upload report (refer to [CLI docs](../cli/README.md)). +## Artifacts generation and loading + +In addition to running ESLint from the plugin implementation, you can configure the plugin to consume pre-generated ESLint reports (artifacts). This is particularly useful for: + +- **CI/CD pipelines**: Use cached lint results from your build system +- **Monorepo setups**: Aggregate results from multiple projects or targets +- **Performance optimization**: Skip ESLint execution when reports are already available +- **Custom workflows**: Integrate with existing linting infrastructure + +The artifacts feature supports loading ESLint JSON reports that follow the standard `ESLint.LintResult[]` format. + +### Basic artifact configuration + +Specify the path(s) to your ESLint JSON report files: + +```js +import eslintPlugin from '@code-pushup/eslint-plugin'; + +export default { + plugins: [ + await eslintPlugin({ + artifacts: { + artifactsPaths: './eslint-report.json', + }, + }), + ], +}; +``` + +### Multiple artifact files + +Use glob patterns to aggregate results from multiple files: + +```js +export default { + plugins: [ + await eslintPlugin({ + artifacts: { + artifactsPaths: ['packages/**/eslint-report.json', 'apps/**/.eslint/*.json'], + }, + }), + ], +}; +``` + +### Generate artifacts with custom command + +If you need to generate the artifacts before loading them, use the `generateArtifactsCommand` option: + +```js +export default { + plugins: [ + await eslintPlugin({ + artifacts: { + generateArtifactsCommand: 'npm run lint:report', + artifactsPaths: './eslint-report.json', + }, + }), + ], +}; +``` + +You can also specify the command with arguments: + +```js +export default { + plugins: [ + await eslintPlugin({ + artifacts: { + generateArtifactsCommand: { + command: 'eslint', + args: ['src/**/*.{js,ts}', '--format=json', '--output-file=eslint-report.json'], + }, + artifactsPaths: './eslint-report.json', + }, + }), + ], +}; +``` + ## Nx Monorepo Setup -Find all details in our [Nx setup guide](https://github.com/code-pushup/cli/wiki/Code-PushUp-integration-guide-for-Nx-monorepos#eslint-config). +### Caching artifact generation + +To leverage Nx's caching capabilities, you need to generate a JSON artifact for caching, while still being able to see the ESLint violations in the terminal or CI logs, so you can fix them. +This can be done by leveraging eslint formatter. + +_lint target from nx.json_ + +```json +{ + "lint": { + "inputs": ["lint-eslint-inputs"], + "outputs": ["{projectRoot}/.eslint/**/*"], + "cache": true, + "executor": "nx:run-commands", + "options": { + "command": "eslint", + "args": ["{projectRoot}/**/*.ts", "{projectRoot}/package.json", "--config={projectRoot}/eslint.config.js", "--max-warnings=0", "--no-warn-ignored", "--error-on-unmatched-pattern=false", "--format=@code-pushup/eslint-formatter-multi"], + "env": { + "ESLINT_FORMATTER_CONFIG": "{\"outputDir\":\"{projectRoot}/.eslint\"}" + } + } + } +} +``` + +As you can now generate the `eslint-report.json` from cache your plugin configuration can directly consume them. + +_code-pushup.config.ts target from nx.json_ + +```jsonc +{ + "code-pushup": { + "dependsOn": ["lint"], + // also multiple targets can be merged into one report + // "dependsOn": ["lint", "lint-next"], + "executor": "nx:run-commands", + "options": { + "command": "npx code-pushup", + }, + }, +} +``` + +and the project configuration leverages `dependsOn` to ensure the artefacts are generated when running code-pushup. + +Your `code-pushup.config.ts` can then be configured to consume the cached artifacts: + +```js +import eslintPlugin from '@code-pushup/eslint-plugin'; + +export default { + plugins: [ + await eslintPlugin({ + artifacts: { + artifactsPaths: 'packages/**/.eslint/eslint-report-*.json', + }, + }), + ], +}; +``` + +--- + +Find more details in our [Nx setup guide](https://github.com/code-pushup/cli/wiki/Code-PushUp-integration-guide-for-Nx-monorepos#eslint-config). diff --git a/packages/plugin-eslint/src/lib/runner.int.test.ts b/packages/plugin-eslint/src/lib/runner.int.test.ts index 8cb7c5b48..865724d9f 100644 --- a/packages/plugin-eslint/src/lib/runner.int.test.ts +++ b/packages/plugin-eslint/src/lib/runner.int.test.ts @@ -39,8 +39,8 @@ describe('executeRunner', () => { beforeAll(() => { cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(appDir); - // Use the actual platform for proper glob handling - platformSpy = vi.spyOn(os, 'platform').mockReturnValue(process.platform); + // Windows does not require additional quotation marks for globs + platformSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); }); afterAll(() => { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b8f95a0f6..099273815 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -8,15 +8,6 @@ export { toTitleCase, uppercase, } from './lib/case-conversions.js'; -export { - buildCommandString, - filePathToCliArg, - formatCommandLog, - type FormatCommandLogOptions, - formatEnvValue, - objectToCliArgs, - type CliArgsObject, -} from './lib/command.js'; export { filesCoverageToTree, type FileCoverage } from './lib/coverage-tree.js'; export { createRunnerFiles } from './lib/create-runner-files.js'; export { comparePairs, matchArrayItemsByKey, type Diff } from './lib/diff.js'; @@ -42,6 +33,7 @@ export { directoryExists, ensureDirectoryExists, fileExists, + filePathToCliArg, findLineNumberInText, findNearestFile, importModule, @@ -132,6 +124,7 @@ export { factorOf, fromJsonLines, objectFromEntries, + objectToCliArgs, objectToEntries, objectToKeys, removeUndefinedAndEmptyProps, @@ -141,6 +134,7 @@ export { toOrdinal, toUnixNewlines, toUnixPath, + type CliArgsObject, } from './lib/transform.js'; export type { CamelCaseToKebabCase, diff --git a/packages/utils/src/lib/command.int.test.ts b/packages/utils/src/lib/command.int.test.ts deleted file mode 100644 index f8590d63d..000000000 --- a/packages/utils/src/lib/command.int.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { removeColorCodes } from '@code-pushup/test-utils'; -import { formatCommandLog } from './command.js'; - -describe('formatCommandLog', () => { - it('should format simple command', () => { - const result = removeColorCodes( - formatCommandLog({ command: 'npx', args: ['command', '--verbose'] }), - ); - - expect(result).toBe('$ npx command --verbose'); - }); - - it('should format simple command with explicit process.cwd()', () => { - const result = removeColorCodes( - formatCommandLog({ - command: 'npx', - args: ['command', '--verbose'], - cwd: process.cwd(), - }), - ); - - expect(result).toBe('$ npx command --verbose'); - }); - - it('should format simple command with relative cwd', () => { - const result = removeColorCodes( - formatCommandLog({ - command: 'npx', - args: ['command', '--verbose'], - cwd: './wololo', - }), - ); - - expect(result).toBe(`wololo $ npx command --verbose`); - }); - - it('should format simple command with absolute non-current path converted to relative', () => { - const result = removeColorCodes( - formatCommandLog({ - command: 'npx', - args: ['command', '--verbose'], - cwd: path.join(process.cwd(), 'tmp'), - }), - ); - expect(result).toBe('tmp $ npx command --verbose'); - }); - - it('should format simple command with relative cwd in parent folder', () => { - const result = removeColorCodes( - formatCommandLog({ - command: 'npx', - args: ['command', '--verbose'], - cwd: '..', - }), - ); - - expect(result).toBe(`.. $ npx command --verbose`); - }); - - it('should format simple command using relative path to parent directory', () => { - const result = removeColorCodes( - formatCommandLog({ - command: 'npx', - args: ['command', '--verbose'], - cwd: path.dirname(process.cwd()), - }), - ); - - expect(result).toBe('.. $ npx command --verbose'); - }); - - it('should format command with environment variables', () => { - const result = removeColorCodes( - formatCommandLog({ - command: 'npx', - args: ['command'], - cwd: process.cwd(), - env: { - NODE_ENV: 'production', - DEBUG: 'true', - }, - }), - ); - - expect(result).toBe('$ NODE_ENV=production DEBUG=true npx command'); - }); - - it('should handle environment variables with quotes in values', () => { - const result = removeColorCodes( - formatCommandLog({ - command: 'npx', - args: ['command'], - cwd: process.cwd(), - env: { - MESSAGE: 'Hello "world"', - }, - }), - ); - - expect(result).toBe('$ MESSAGE="Hello world" npx command'); - }); -}); diff --git a/packages/utils/src/lib/command.ts b/packages/utils/src/lib/command.ts deleted file mode 100644 index 1eee3aba4..000000000 --- a/packages/utils/src/lib/command.ts +++ /dev/null @@ -1,168 +0,0 @@ -import ansis from 'ansis'; -import path from 'node:path'; - -type ArgumentValue = number | string | boolean | string[] | undefined; -export type CliArgsObject> = - T extends never - ? Record | { _: string } - : T; - -/** - * Escapes command line arguments that contain spaces, quotes, or other special characters. - * - * @param {string[]} args - Array of command arguments to escape. - * @returns {string[]} - Array of escaped arguments suitable for shell execution. - */ -export function escapeCliArgs(args: string[]): string[] { - return args.map(arg => { - if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { - return `"${arg.replace(/"/g, '\\"')}"`; - } - return arg; - }); -} - -/** - * Formats environment variable values for display by stripping quotes and then escaping. - * - * @param {string} value - Environment variable value to format. - * @returns {string} - Formatted and escaped value suitable for display. - */ -export function formatEnvValue(value: string): string { - // Strip quotes from the value for display - const cleanValue = value.replace(/"/g, ''); - return escapeCliArgs([cleanValue])[0] ?? cleanValue; -} - -/** - * Builds a command string by escaping arguments that contain spaces, quotes, or other special characters. - * - * @param {string} command - The base command to execute. - * @param {string[]} args - Array of command arguments. - * @returns {string} - The complete command string with properly escaped arguments. - */ -export function buildCommandString( - command: string, - args: string[] = [], -): string { - if (args.length === 0) { - return command; - } - - return `${command} ${escapeCliArgs(args).join(' ')}`; -} - -/** - * Options for formatting a command log. - */ -export interface FormatCommandLogOptions { - command: string; - args?: string[]; - cwd?: string; - env?: Record; -} - -/** - * Formats a command string with optional cwd prefix, environment variables, and ANSI colors. - * - * @param {FormatCommandLogOptions} options - Command formatting options. - * @returns {string} - ANSI-colored formatted command string. - * - * @example - * - * formatCommandLog({cwd: 'tools/api', env: {API_KEY='•••' NODE_ENV='prod'}, command: 'node', args: ['cli.js', '--do', 'thing', 'fast']}) - * ┌─────────────────────────────────────────────────────────────────────────┐ - * │ tools/api $ API_KEY="•••" NODE_ENV="prod" node cli.js --do thing fast │ - * │ │ │ │ │ │ │ - * │ └ cwd │ │ │ └ args. │ - * │ │ │ └ command │ - * │ │ └ env variables │ - * │ └ prompt symbol ($) │ - * └─────────────────────────────────────────────────────────────────────────┘ - * - */ -export function formatCommandLog(options: FormatCommandLogOptions): string { - const { command, args = [], cwd = process.cwd(), env } = options; - const relativeDir = path.relative(process.cwd(), cwd); - - return [ - ...(relativeDir && relativeDir !== '.' - ? [ansis.italic(ansis.gray(relativeDir))] - : []), - ansis.yellow('$'), - ...(env && Object.keys(env).length > 0 - ? Object.entries(env).map(([key, value]) => { - return ansis.gray(`${key}=${formatEnvValue(value)}`); - }) - : []), - ansis.gray(command), - ansis.gray(escapeCliArgs(args).join(' ')), - ].join(' '); -} - -/** - * Converts an object with different types of values into an array of command-line arguments. - * - * @example - * const args = objectToCliArgs({ - * _: ['node', 'index.js'], // node index.js - * name: 'Juanita', // --name=Juanita - * formats: ['json', 'md'] // --format=json --format=md - * }); - */ -export function objectToCliArgs< - T extends object = Record, ->(params?: CliArgsObject): string[] { - if (!params) { - return []; - } - - return Object.entries(params).flatMap(([key, value]) => { - // process/file/script - if (key === '_') { - return Array.isArray(value) ? value : [`${value}`]; - } - const prefix = key.length === 1 ? '-' : '--'; - // "-*" arguments (shorthands) - if (Array.isArray(value)) { - return value.map(v => `${prefix}${key}="${v}"`); - } - // "--*" arguments ========== - - if (typeof value === 'object') { - return Object.entries(value as Record).flatMap( - // transform nested objects to the dot notation `key.subkey` - ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), - ); - } - - 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 (typeof value === 'undefined') { - return []; - } - - throw new Error(`Unsupported type ${typeof value} for key ${key}`); - }); -} - -/** - * Converts a file path to a CLI argument by wrapping it in quotes to handle spaces. - * - * @param {string} filePath - The file path to convert to a CLI argument. - * @returns {string} - The quoted file path suitable for CLI usage. - */ -export function filePathToCliArg(filePath: string): string { - // needs to be escaped if spaces included - return `"${filePath}"`; -} diff --git a/packages/utils/src/lib/command.unit.test.ts b/packages/utils/src/lib/command.unit.test.ts deleted file mode 100644 index 6f2706fb3..000000000 --- a/packages/utils/src/lib/command.unit.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - buildCommandString, - escapeCliArgs, - filePathToCliArg, - objectToCliArgs, -} from './command.js'; - -describe('filePathToCliArg', () => { - it('should wrap path in quotes', () => { - expect(filePathToCliArg('My Project/index.js')).toBe( - '"My Project/index.js"', - ); - }); -}); - -describe('escapeCliArgs', () => { - it('should return empty array for empty input', () => { - const args: string[] = []; - const result = escapeCliArgs(args); - expect(result).toEqual([]); - }); - - it('should return arguments unchanged when no special characters', () => { - const args = ['simple', 'arguments', '--flag', 'value']; - const result = escapeCliArgs(args); - expect(result).toEqual(['simple', 'arguments', '--flag', 'value']); - }); - - it('should escape arguments containing spaces', () => { - const args = ['file with spaces.txt', 'normal']; - const result = escapeCliArgs(args); - expect(result).toEqual(['"file with spaces.txt"', 'normal']); - }); - - it('should escape arguments containing double quotes', () => { - const args = ['say "hello"', 'normal']; - const result = escapeCliArgs(args); - expect(result).toEqual(['"say \\"hello\\""', 'normal']); - }); - - it('should escape arguments containing single quotes', () => { - const args = ["don't", 'normal']; - const result = escapeCliArgs(args); - expect(result).toEqual(['"don\'t"', 'normal']); - }); - - it('should escape arguments containing both quote types', () => { - const args = ['mixed "double" and \'single\' quotes']; - const result = escapeCliArgs(args); - expect(result).toEqual(['"mixed \\"double\\" and \'single\' quotes"']); - }); - - it('should escape arguments containing multiple spaces', () => { - const args = ['multiple spaces here']; - const result = escapeCliArgs(args); - expect(result).toEqual(['"multiple spaces here"']); - }); - - it('should handle empty string arguments', () => { - const args = ['', 'normal', '']; - const result = escapeCliArgs(args); - expect(result).toEqual(['', 'normal', '']); - }); - - it('should handle arguments with only spaces', () => { - const args = [' ', 'normal']; - const result = escapeCliArgs(args); - expect(result).toEqual(['" "', 'normal']); - }); - - it('should handle complex mix of arguments', () => { - const args = [ - 'simple', - 'with spaces', - 'with"quotes', - "with'apostrophe", - '--flag', - 'value', - ]; - const result = escapeCliArgs(args); - expect(result).toEqual([ - 'simple', - '"with spaces"', - '"with\\"quotes"', - '"with\'apostrophe"', - '--flag', - 'value', - ]); - }); - - it('should handle arguments with consecutive quotes', () => { - const args = ['""""', "''''"]; - const result = escapeCliArgs(args); - expect(result).toEqual(['"\\"\\"\\"\\""', "\"''''\""]); - }); -}); - -describe('objectToCliArgs', () => { - it('should handle undefined', () => { - const params = { unsupported: undefined as any }; - expect(objectToCliArgs(params)).toStrictEqual([]); - }); - - 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 nested objects', () => { - const params = { persist: { format: ['json', 'md'], verbose: false } }; - const result = objectToCliArgs(params); - expect(result).toEqual([ - '--persist.format="json"', - '--persist.format="md"', - '--no-persist.verbose', - ]); - }); - - it('should throw error for unsupported type', () => { - expect(() => objectToCliArgs({ param: Symbol('') })).toThrow( - 'Unsupported type', - ); - }); -}); - -describe('buildCommandString', () => { - it('should return command only when no arguments provided', () => { - const command = 'npm'; - const result = buildCommandString(command); - expect(result).toBe('npm'); - }); - - it('should return command only when empty arguments array provided', () => { - const command = 'npm'; - const result = buildCommandString(command, []); - expect(result).toBe('npm'); - }); - - it('should handle simple arguments without special characters', () => { - const command = 'npm'; - const args = ['install', '--save-dev', 'vitest']; - const result = buildCommandString(command, args); - expect(result).toBe('npm install --save-dev vitest'); - }); - - it('should escape arguments containing spaces', () => { - const command = 'code'; - const args = ['My Project/index.js']; - const result = buildCommandString(command, args); - expect(result).toBe('code "My Project/index.js"'); - }); - - it('should escape arguments containing double quotes', () => { - const command = 'echo'; - const args = ['Hello "World"']; - const result = buildCommandString(command, args); - expect(result).toBe('echo "Hello \\"World\\""'); - }); - - it('should escape arguments containing single quotes', () => { - const command = 'echo'; - const args = ["Hello 'World'"]; - const result = buildCommandString(command, args); - expect(result).toBe('echo "Hello \'World\'"'); - }); - - it('should handle mixed arguments with and without special characters', () => { - const command = 'mycommand'; - const args = ['simple', 'with spaces', '--flag', 'with "quotes"']; - const result = buildCommandString(command, args); - expect(result).toBe( - 'mycommand simple "with spaces" --flag "with \\"quotes\\""', - ); - }); - - it('should handle arguments with multiple types of quotes', () => { - const command = 'test'; - const args = ['arg with "double" and \'single\' quotes']; - const result = buildCommandString(command, args); - expect(result).toBe('test "arg with \\"double\\" and \'single\' quotes"'); - }); - - it('should handle objects with undefined', () => { - const params = { format: undefined }; - const result = objectToCliArgs(params); - expect(result).toStrictEqual([]); - }); - - it('should handle empty string arguments', () => { - const command = 'test'; - const args = ['', 'normal']; - const result = buildCommandString(command, args); - expect(result).toBe('test normal'); - }); - - it('should handle arguments with only spaces', () => { - const command = 'test'; - const args = [' ']; - const result = buildCommandString(command, args); - expect(result).toBe('test " "'); - }); -}); diff --git a/packages/utils/src/lib/execute-process.ts b/packages/utils/src/lib/execute-process.ts index 2cdc27922..d1fa98a3f 100644 --- a/packages/utils/src/lib/execute-process.ts +++ b/packages/utils/src/lib/execute-process.ts @@ -1,6 +1,13 @@ -import { type ChildProcess, exec } from 'node:child_process'; -import { buildCommandString, formatCommandLog } from './command.js'; +import { + type ChildProcess, + type ChildProcessByStdio, + type SpawnOptionsWithStdioTuple, + type StdioPipe, + spawn, +} from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; import { isVerbose } from './env.js'; +import { formatCommandLog } from './format-command-log.js'; import { ui } from './logging.js'; import { calcDuration } from './reports/utils.js'; @@ -80,11 +87,12 @@ export class ProcessError extends Error { * args: ['--version'] * */ -export type ProcessConfig = { +export type ProcessConfig = Omit< + SpawnOptionsWithStdioTuple, + 'stdio' +> & { command: string; args?: string[]; - cwd?: string; - env?: Record; observer?: ProcessObserver; ignoreExitCode?: boolean; }; @@ -99,12 +107,12 @@ export type ProcessConfig = { * * @example * const observer = { - * onStdout: (stdout, childProcess) => console.info(stdout) + * onStdout: (stdout) => console.info(stdout) * } */ export type ProcessObserver = { - onStdout?: (stdout: string, childProcess: ChildProcess) => void; - onStderr?: (stderr: string, childProcess: ChildProcess) => void; + onStdout?: (stdout: string, sourceProcess?: ChildProcess) => void; + onStderr?: (stderr: string, sourceProcess?: ChildProcess) => void; onError?: (error: ProcessError) => void; onComplete?: () => void; }; @@ -138,54 +146,45 @@ export type ProcessObserver = { * @param cfg - see {@link ProcessConfig} */ export function executeProcess(cfg: ProcessConfig): Promise { - const { observer, cwd, command, args, ignoreExitCode = false, env } = cfg; + const { command, args, observer, ignoreExitCode = false, ...options } = cfg; const { onStdout, onStderr, onError, onComplete } = observer ?? {}; const date = new Date().toISOString(); const start = performance.now(); if (isVerbose()) { ui().logger.log( - formatCommandLog({ - command, - args, - cwd: cfg.cwd ?? process.cwd(), - env, - }), + formatCommandLog(command, args, `${cfg.cwd ?? process.cwd()}`), ); } return new Promise((resolve, reject) => { - const commandString = buildCommandString(command, args ?? []); + // shell:true tells Windows to use shell command for spawning a child process + const spawnedProcess = spawn(command, args ?? [], { + shell: true, + windowsHide: true, + ...options, + }) as ChildProcessByStdio; - const childProcess = exec(commandString, { - cwd, - env: env ? { ...process.env, ...env } : process.env, - windowsHide: false, - }); // eslint-disable-next-line functional/no-let let stdout = ''; // eslint-disable-next-line functional/no-let let stderr = ''; - if (childProcess.stdout) { - childProcess.stdout.on('data', data => { - stdout += String(data); - onStdout?.(String(data), childProcess); - }); - } + spawnedProcess.stdout.on('data', data => { + stdout += String(data); + onStdout?.(String(data), spawnedProcess); + }); - if (childProcess.stderr) { - childProcess.stderr.on('data', data => { - stderr += String(data); - onStderr?.(String(data), childProcess); - }); - } + spawnedProcess.stderr.on('data', data => { + stderr += String(data); + onStderr?.(String(data), spawnedProcess); + }); - childProcess.on('error', err => { + spawnedProcess.on('error', err => { stderr += err.toString(); }); - childProcess.on('close', code => { + spawnedProcess.on('close', code => { const timings = { date, duration: calcDuration(start) }; if (code === 0 || ignoreExitCode) { onComplete?.(); diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index c04560edb..268eed737 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -167,6 +167,11 @@ export function findLineNumberInText( return lineNumber === 0 ? null : lineNumber; // If the package isn't found, return null } +export function filePathToCliArg(filePath: string): string { + // needs to be escaped if spaces included + return `"${filePath}"`; +} + export function projectToFilename(project: string): string { return project.replace(/[/\\\s]+/g, '-').replace(/@/g, ''); } diff --git a/packages/utils/src/lib/file-system.unit.test.ts b/packages/utils/src/lib/file-system.unit.test.ts index be3c1cc5f..b141ee4ff 100644 --- a/packages/utils/src/lib/file-system.unit.test.ts +++ b/packages/utils/src/lib/file-system.unit.test.ts @@ -3,12 +3,12 @@ import { stat } from 'node:fs/promises'; import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import { filePathToCliArg } from './command.js'; import { type FileResult, crawlFileSystem, createReportPath, ensureDirectoryExists, + filePathToCliArg, findLineNumberInText, findNearestFile, logMultipleFileResults, @@ -270,6 +270,14 @@ describe('findLineNumberInText', () => { }); }); +describe('filePathToCliArg', () => { + it('should wrap path in quotes', () => { + expect(filePathToCliArg('My Project/index.js')).toBe( + '"My Project/index.js"', + ); + }); +}); + describe('projectToFilename', () => { it.each([ ['frontend', 'frontend'], diff --git a/packages/utils/src/lib/format-command-log.int.test.ts b/packages/utils/src/lib/format-command-log.int.test.ts new file mode 100644 index 000000000..28a916a55 --- /dev/null +++ b/packages/utils/src/lib/format-command-log.int.test.ts @@ -0,0 +1,61 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { removeColorCodes } from '@code-pushup/test-utils'; +import { formatCommandLog } from './format-command-log.js'; + +describe('formatCommandLog', () => { + it('should format simple command', () => { + const result = removeColorCodes( + formatCommandLog('npx', ['command', '--verbose']), + ); + + expect(result).toBe('$ npx command --verbose'); + }); + + it('should format simple command with explicit process.cwd()', () => { + const result = removeColorCodes( + formatCommandLog('npx', ['command', '--verbose'], process.cwd()), + ); + + expect(result).toBe('$ npx command --verbose'); + }); + + it('should format simple command with relative cwd', () => { + const result = removeColorCodes( + formatCommandLog('npx', ['command', '--verbose'], './wololo'), + ); + + expect(result).toBe(`wololo $ npx command --verbose`); + }); + + it('should format simple command with absolute non-current path converted to relative', () => { + const result = removeColorCodes( + formatCommandLog( + 'npx', + ['command', '--verbose'], + path.join(process.cwd(), 'tmp'), + ), + ); + expect(result).toBe('tmp $ npx command --verbose'); + }); + + it('should format simple command with relative cwd in parent folder', () => { + const result = removeColorCodes( + formatCommandLog('npx', ['command', '--verbose'], '..'), + ); + + expect(result).toBe(`.. $ npx command --verbose`); + }); + + it('should format simple command using relative path to parent directory', () => { + const result = removeColorCodes( + formatCommandLog( + 'npx', + ['command', '--verbose'], + path.dirname(process.cwd()), + ), + ); + + expect(result).toBe('.. $ npx command --verbose'); + }); +}); diff --git a/packages/utils/src/lib/format-command-log.ts b/packages/utils/src/lib/format-command-log.ts new file mode 100644 index 000000000..0ce5a89cd --- /dev/null +++ b/packages/utils/src/lib/format-command-log.ts @@ -0,0 +1,27 @@ +import ansis from 'ansis'; +import path from 'node:path'; + +/** + * Formats a command string with optional cwd prefix and ANSI colors. + * + * @param {string} command - The command to execute. + * @param {string[]} args - Array of command arguments. + * @param {string} [cwd] - Optional current working directory for the command. + * @returns {string} - ANSI-colored formatted command string. + */ +export function formatCommandLog( + command: string, + args: string[] = [], + cwd: string = process.cwd(), +): string { + const relativeDir = path.relative(process.cwd(), cwd); + + return [ + ...(relativeDir && relativeDir !== '.' + ? [ansis.italic(ansis.gray(relativeDir))] + : []), + ansis.yellow('$'), + ansis.gray(command), + ansis.gray(args.map(arg => arg).join(' ')), + ].join(' '); +} diff --git a/packages/utils/src/lib/transform.ts b/packages/utils/src/lib/transform.ts index 7c56a859c..86caf9aef 100644 --- a/packages/utils/src/lib/transform.ts +++ b/packages/utils/src/lib/transform.ts @@ -44,6 +44,68 @@ export function factorOf(items: T[], filterFn: (i: T) => boolean): number { return filterCount === 0 ? 1 : (itemCount - filterCount) / itemCount; } +type ArgumentValue = number | string | boolean | string[]; +export type CliArgsObject> = + T extends never + ? Record | { _: string } + : T; + +/** + * Converts an object with different types of values into an array of command-line arguments. + * + * @example + * const args = objectToCliArgs({ + * _: ['node', 'index.js'], // node index.js + * name: 'Juanita', // --name=Juanita + * formats: ['json', 'md'] // --format=json --format=md + * }); + */ +export function objectToCliArgs< + T extends object = Record, +>(params?: CliArgsObject): string[] { + if (!params) { + return []; + } + + return Object.entries(params).flatMap(([key, value]) => { + // process/file/script + if (key === '_') { + return Array.isArray(value) ? value : [`${value}`]; + } + const prefix = key.length === 1 ? '-' : '--'; + // "-*" arguments (shorthands) + if (Array.isArray(value)) { + return value.map(v => `${prefix}${key}="${v}"`); + } + // "--*" arguments ========== + + if (Array.isArray(value)) { + return value.map(v => `${prefix}${key}="${v}"`); + } + + if (typeof value === 'object') { + return Object.entries(value as Record).flatMap( + // transform nested objects to the dot notation `key.subkey` + ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), + ); + } + + 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}`]; + } + + throw new Error(`Unsupported type ${typeof value} for key ${key}`); + }); +} + export function toUnixPath(path: string): string { return path.replace(/\\/g, '/'); } diff --git a/packages/utils/src/lib/transform.unit.test.ts b/packages/utils/src/lib/transform.unit.test.ts index b8526504a..b72982e3d 100644 --- a/packages/utils/src/lib/transform.unit.test.ts +++ b/packages/utils/src/lib/transform.unit.test.ts @@ -6,6 +6,7 @@ import { factorOf, fromJsonLines, objectFromEntries, + objectToCliArgs, objectToEntries, objectToKeys, removeUndefinedAndEmptyProps, @@ -164,6 +165,73 @@ describe('factorOf', () => { }); }); +describe('objectToCliArgs', () => { + 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 nested objects', () => { + const params = { persist: { format: ['json', 'md'], verbose: false } }; + const result = objectToCliArgs(params); + expect(result).toEqual([ + '--persist.format="json"', + '--persist.format="md"', + '--no-persist.verbose', + ]); + }); + + it('should throw error for unsupported type', () => { + const params = { unsupported: undefined as any }; + expect(() => objectToCliArgs(params)).toThrow('Unsupported type'); + }); +}); + describe('toUnixPath', () => { it.each([ ['main.ts', 'main.ts'], diff --git a/testing/test-nx-utils/src/lib/utils/nx.ts b/testing/test-nx-utils/src/lib/utils/nx.ts index fecf433fa..050bdc541 100644 --- a/testing/test-nx-utils/src/lib/utils/nx.ts +++ b/testing/test-nx-utils/src/lib/utils/nx.ts @@ -84,7 +84,7 @@ export async function nxShowProjectJson( ) { const { code, stderr, stdout } = await executeProcess({ command: 'npx', - args: ['nx', 'show', 'project', '--json', project], + args: ['nx', 'show', `project --json ${project}`], cwd, }); From 6b075eee7b2a2f55a7d189c504c453feeaa8143a Mon Sep 17 00:00:00 2001 From: John Doe Date: Sat, 4 Oct 2025 13:39:13 +0200 Subject: [PATCH 45/48] refactor: fix targets --- packages/nx-plugin/src/executors/cli/executor.ts | 5 +++-- .../src/plugin/target/executor-target.ts | 16 +++++++--------- .../plugin/target/executor.target.unit.test.ts | 12 +++++++++--- .../src/plugin/target/targets.unit.test.ts | 3 +++ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 7eece1129..37c0b1d94 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -27,10 +27,11 @@ export default async function runAutorunExecutor( mergedOptions, normalizedContext, ); - const { dryRun, verbose, command } = mergedOptions; + const { dryRun, verbose, command, bin } = mergedOptions; const commandString = createCliCommandString({ command, args: cliArgumentObject, + bin, }); if (verbose) { logger.info(`Run CLI executor ${command ?? ''}`); @@ -41,7 +42,7 @@ export default async function runAutorunExecutor( } else { try { await executeProcess({ - ...createCliCommandObject({ command, args: cliArgumentObject }), + ...createCliCommandObject({ command, args: cliArgumentObject, bin }), ...(context.cwd ? { cwd: context.cwd } : {}), }); } catch (error) { diff --git a/packages/nx-plugin/src/plugin/target/executor-target.ts b/packages/nx-plugin/src/plugin/target/executor-target.ts index e8b52eb8f..a44c7281e 100644 --- a/packages/nx-plugin/src/plugin/target/executor-target.ts +++ b/packages/nx-plugin/src/plugin/target/executor-target.ts @@ -6,15 +6,13 @@ export function createExecutorTarget(options?: { bin?: string; projectPrefix?: string; }): TargetConfiguration { - const { bin = PACKAGE_NAME, projectPrefix } = options ?? {}; + const { bin, projectPrefix } = options ?? {}; + return { - executor: `${bin}:cli`, - ...(projectPrefix - ? { - options: { - projectPrefix, - }, - } - : {}), + executor: `${PACKAGE_NAME}:cli`, + options: { + ...(bin ? { bin } : {}), + ...(projectPrefix ? { projectPrefix } : {}), + }, }; } diff --git a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts index 610b44bd7..571b63aa2 100644 --- a/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts +++ b/packages/nx-plugin/src/plugin/target/executor.target.unit.test.ts @@ -1,16 +1,22 @@ -import { expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { createExecutorTarget } from './executor-target.js'; describe('createExecutorTarget', () => { it('should return executor target without project name', () => { expect(createExecutorTarget()).toStrictEqual({ executor: '@code-pushup/nx-plugin:cli', + options: {}, }); }); it('should use bin if provides', () => { - expect(createExecutorTarget({ bin: 'xyz' })).toStrictEqual({ - executor: 'xyz:cli', + expect( + createExecutorTarget({ bin: 'packages/cli/src/index.ts' }), + ).toStrictEqual({ + executor: '@code-pushup/nx-plugin:cli', + options: { + bin: 'packages/cli/src/index.ts', + }, }); }); 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..919c235a3 100644 --- a/packages/nx-plugin/src/plugin/target/targets.unit.test.ts +++ b/packages/nx-plugin/src/plugin/target/targets.unit.test.ts @@ -109,6 +109,7 @@ describe('createTargets', () => { expect.objectContaining({ [targetName]: { executor: `${PACKAGE_NAME}:cli`, + options: {}, }, }), ); @@ -133,6 +134,7 @@ describe('createTargets', () => { ).resolves.toStrictEqual({ [DEFAULT_TARGET_NAME]: { executor: '@code-pushup/nx-plugin:cli', + options: {}, }, }); }); @@ -158,6 +160,7 @@ describe('createTargets', () => { ).resolves.toStrictEqual({ cp: { executor: '@code-pushup/nx-plugin:cli', + options: {}, }, }); }); From 30872df4ad2617670d7e65e3d24a9f785e75d590 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sat, 4 Oct 2025 17:09:52 +0200 Subject: [PATCH 46/48] refactor: fix e2e for nx-plugin --- .../tests/plugin-create-nodes.e2e.test.ts | 14 +++++++------- packages/nx-plugin/src/executors/cli/README.md | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index 818073559..1fc21fae3 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -174,18 +174,18 @@ describe('nx-plugin', () => { const cleanStdout = removeColorCodes(stdout); expect(cleanStdout).toContain('nx run my-lib:code-pushup'); - expect(cleanStdout).toContain('$ npx @code-pushup/cli '); + expect(cleanStdout).toContain('npx @code-pushup/cli'); expect(cleanStdout).toContain('--dryRun --verbose'); - expect(cleanStdout).toContain(`--upload.project=\\"${project}\\"`); + expect(cleanStdout).toContain(`--upload.project="${project}"`); }); - it('should consider plugin option cliBin in executor target', async () => { - const cwd = path.join(testFileDir, 'executor-option-cliBin'); - const cliBinPath = `packages/cli/dist`; + it('should consider plugin option bin in executor target', async () => { + const cwd = path.join(testFileDir, 'executor-option-bin'); + const binPath = `packages/cli/dist`; registerPluginInWorkspace(tree, { plugin: '@code-pushup/nx-plugin', options: { - cliBin: cliBinPath, + bin: binPath, }, }); const { root } = readProjectConfiguration(tree, project); @@ -199,7 +199,7 @@ describe('nx-plugin', () => { expect(projectJson.targets).toStrictEqual({ 'code-pushup': expect.objectContaining({ options: { - bin: cliBinPath, + bin: binPath, }, }), }); diff --git a/packages/nx-plugin/src/executors/cli/README.md b/packages/nx-plugin/src/executors/cli/README.md index fdb01c9f7..720432911 100644 --- a/packages/nx-plugin/src/executors/cli/README.md +++ b/packages/nx-plugin/src/executors/cli/README.md @@ -7,10 +7,10 @@ For details on the CLI command read the [CLI commands documentation](https://git ## Usage -Configure a target in your project json. +Configure a target in your `project.json`. ```jsonc -// /project.json +// {projectRoot}/project.json { "name": "my-project", "targets": { @@ -74,4 +74,4 @@ Show what will be executed without actually executing it: | **dryRun** | `boolean` | To debug the executor, dry run the command without real execution. | | **bin** | `string` | Path to Code PushUp CLI | -For all other options see the [CLI autorun documentation](../../../../cli/README.md#autorun-command). +For all other options, see the [CLI autorun documentation](../../../../cli/README.md#autorun-command). From 4803bf492c7cfc57a96492298f4f37f2100d1598 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sat, 4 Oct 2025 17:14:53 +0200 Subject: [PATCH 47/48] refactor: fix e2e for nx-plugin 2 --- e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index 1fc21fae3..a3747b240 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -176,7 +176,7 @@ describe('nx-plugin', () => { expect(cleanStdout).toContain('nx run my-lib:code-pushup'); expect(cleanStdout).toContain('npx @code-pushup/cli'); expect(cleanStdout).toContain('--dryRun --verbose'); - expect(cleanStdout).toContain(`--upload.project="${project}"`); + expect(cleanStdout).toBe(`--upload.project="${project}"`); }); it('should consider plugin option bin in executor target', async () => { From dc2fbf93d538c5c01fb5dc11916641a0d9b5e320 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sat, 4 Oct 2025 17:18:40 +0200 Subject: [PATCH 48/48] refactor: fix e2e for nx-plugin 3 --- e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index a3747b240..1fc21fae3 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -176,7 +176,7 @@ describe('nx-plugin', () => { expect(cleanStdout).toContain('nx run my-lib:code-pushup'); expect(cleanStdout).toContain('npx @code-pushup/cli'); expect(cleanStdout).toContain('--dryRun --verbose'); - expect(cleanStdout).toBe(`--upload.project="${project}"`); + expect(cleanStdout).toContain(`--upload.project="${project}"`); }); it('should consider plugin option bin in executor target', async () => {