diff --git a/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap b/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap index 38dcb9c58..7143fbe32 100644 --- a/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap +++ b/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap @@ -26,6 +26,8 @@ Options: --upload.project Project slug from portal [string] --upload.server URL to your portal server [string] --upload.apiKey API key for the portal server [string] + --onlyPlugins List of plugins to run. If not set all plugins are + run. [array] [default: []] -h, --help Show help [boolean] " `; diff --git a/e2e/cli-e2e/tests/print-config.spec.ts b/e2e/cli-e2e/tests/print-config.spec.ts index 0abeab46b..a67b75a4f 100644 --- a/e2e/cli-e2e/tests/print-config.spec.ts +++ b/e2e/cli-e2e/tests/print-config.spec.ts @@ -37,6 +37,7 @@ describe('print-config', () => { ]), // @TODO add test data to config file categories: expect.any(Array), + onlyPlugins: [], }); }); @@ -61,6 +62,7 @@ describe('print-config', () => { }), plugins: expect.any(Array), categories: expect.any(Array), + onlyPlugins: [], }); }); diff --git a/packages/cli/src/lib/autorun/command-object.ts b/packages/cli/src/lib/autorun/command-object.ts index 32f99c414..eb710302e 100644 --- a/packages/cli/src/lib/autorun/command-object.ts +++ b/packages/cli/src/lib/autorun/command-object.ts @@ -7,6 +7,7 @@ import { upload, } from '@code-pushup/core'; import { CLI_NAME } from '../cli'; +import { onlyPluginsOption } from '../implementation/only-config-option'; type AutorunOptions = CollectOptions & UploadOptions; @@ -15,6 +16,9 @@ export function yargsAutorunCommandObject() { return { command, describe: 'Shortcut for running collect followed by upload', + builder: { + onlyPlugins: onlyPluginsOption, + }, handler: async (args: ArgumentsCamelCase) => { console.log(chalk.bold(CLI_NAME)); console.log(chalk.gray(`Run ${command}...`)); diff --git a/packages/cli/src/lib/collect/command-object.ts b/packages/cli/src/lib/collect/command-object.ts index 8cf53120d..ccf483861 100644 --- a/packages/cli/src/lib/collect/command-object.ts +++ b/packages/cli/src/lib/collect/command-object.ts @@ -5,12 +5,16 @@ import { collectAndPersistReports, } from '@code-pushup/core'; import { CLI_NAME } from '../cli'; +import { onlyPluginsOption } from '../implementation/only-config-option'; export function yargsCollectCommandObject(): CommandModule { const command = 'collect'; return { command, describe: 'Run Plugins and collect results', + builder: { + onlyPlugins: onlyPluginsOption, + }, handler: async (args: ArgumentsCamelCase) => { const options = args as unknown as CollectAndPersistReportsOptions; console.log(chalk.bold(CLI_NAME)); diff --git a/packages/cli/src/lib/implementation/config-middleware.spec.ts b/packages/cli/src/lib/implementation/config-middleware.spec.ts index fea3cec73..8a375f6c2 100644 --- a/packages/cli/src/lib/implementation/config-middleware.spec.ts +++ b/packages/cli/src/lib/implementation/config-middleware.spec.ts @@ -1,7 +1,13 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; -import { expect } from 'vitest'; -import { configMiddleware } from './config-middleware'; +import { SpyInstance, afterEach, beforeEach, describe, expect } from 'vitest'; +import { CoreConfig } from '@code-pushup/models'; +import { + configMiddleware, + filterCategoryByOnlyPluginsOption, + filterPluginsByOnlyPluginsOption, + validateOnlyPluginsOption, +} from './config-middleware'; const __dirname = dirname(fileURLToPath(import.meta.url)); const withDirName = (path: string) => join(__dirname, path); @@ -52,3 +58,110 @@ describe('applyConfigMiddleware', () => { expect(error?.message).toContain(defaultConfigPath); }); }); + +describe('filterPluginsByOnlyPluginsOption', () => { + it('should return all plugins if no onlyPlugins option', async () => { + const plugins = [ + { slug: 'plugin1' }, + { slug: 'plugin2' }, + { slug: 'plugin3' }, + ]; + const filtered = filterPluginsByOnlyPluginsOption( + plugins as CoreConfig['plugins'], + {}, + ); + expect(filtered).toEqual(plugins); + }); + + it('should return only plugins with matching slugs', () => { + const plugins = [ + { slug: 'plugin1' }, + { slug: 'plugin2' }, + { slug: 'plugin3' }, + ]; + const filtered = filterPluginsByOnlyPluginsOption( + plugins as CoreConfig['plugins'], + { + onlyPlugins: ['plugin1', 'plugin3'], + }, + ); + expect(filtered).toEqual([{ slug: 'plugin1' }, { slug: 'plugin3' }]); + }); +}); + +// without the `no-secrets` rule, this would be flagged as a security issue +// eslint-disable-next-line no-secrets/no-secrets +describe('filterCategoryByOnlyPluginsOption', () => { + let logSpy: SpyInstance; + beforeEach(() => { + logSpy = vi.spyOn(console, 'log'); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('should return all categories if no onlyPlugins option', () => { + const categories = [ + { refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] }, + { refs: [{ slug: 'plugin3' }] }, + ]; + const filtered = filterCategoryByOnlyPluginsOption( + categories as CoreConfig['categories'], + {}, + ); + expect(filtered).toEqual(categories); + }); + + it('should return only categories with matching slugs', () => { + const categories = [ + { refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] }, + { refs: [{ slug: 'plugin3' }] }, + ]; + const filtered = filterCategoryByOnlyPluginsOption( + categories as CoreConfig['categories'], + { + onlyPlugins: ['plugin1', 'plugin3'], + }, + ); + expect(filtered).toEqual([{ refs: [{ slug: 'plugin3' }] }]); + }); + + it('should log if category is ignored', () => { + const categories = [ + { title: 'category1', refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] }, + { title: 'category2', refs: [{ slug: 'plugin3' }] }, + ]; + filterCategoryByOnlyPluginsOption(categories as CoreConfig['categories'], { + onlyPlugins: ['plugin1', 'plugin3'], + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"category1"')); + }); +}); + +describe('validateOnlyPluginsOption', () => { + let logSpy: SpyInstance; + beforeEach(() => { + logSpy = vi.spyOn(console, 'log'); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('should log if onlyPlugins option contains non-existing plugin', () => { + const plugins = [{ slug: 'plugin1' }, { slug: 'plugin2' }]; + validateOnlyPluginsOption(plugins as CoreConfig['plugins'], { + onlyPlugins: ['plugin1', 'plugin3'], + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"plugin3"')); + }); + + it('should not log if onlyPlugins option contains existing plugin', () => { + const plugins = [{ slug: 'plugin1' }, { slug: 'plugin2' }]; + validateOnlyPluginsOption(plugins as CoreConfig['plugins'], { + onlyPlugins: ['plugin1'], + }); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/lib/implementation/config-middleware.ts b/packages/cli/src/lib/implementation/config-middleware.ts index 829cff78b..7cf114b55 100644 --- a/packages/cli/src/lib/implementation/config-middleware.ts +++ b/packages/cli/src/lib/implementation/config-middleware.ts @@ -1,29 +1,100 @@ +import chalk from 'chalk'; import { readCodePushupConfig } from '@code-pushup/core'; import { CoreConfig } from '@code-pushup/models'; import { GeneralCliOptions } from './model'; +import { OnlyPluginsOptions } from './only-plugins-options'; export async function configMiddleware< - T extends Partial, + T extends Partial, >(processArgs: T) { const args = processArgs as T; const { config, ...cliOptions } = args as GeneralCliOptions & - Required; + Required & + OnlyPluginsOptions; const importedRc = await readCodePushupConfig(config); - const parsedProcessArgs: CoreConfig & GeneralCliOptions = { - config, - progress: cliOptions.progress, - verbose: cliOptions.verbose, - upload: { - ...importedRc?.upload, - ...cliOptions?.upload, - }, - persist: { - ...importedRc.persist, - ...cliOptions?.persist, - }, - plugins: importedRc.plugins, - categories: importedRc.categories, - }; + + validateOnlyPluginsOption(importedRc.plugins, cliOptions); + + const parsedProcessArgs: CoreConfig & GeneralCliOptions & OnlyPluginsOptions = + { + config, + progress: cliOptions.progress, + verbose: cliOptions.verbose, + upload: { + ...importedRc?.upload, + ...cliOptions?.upload, + }, + persist: { + ...importedRc.persist, + ...cliOptions?.persist, + }, + plugins: filterPluginsByOnlyPluginsOption(importedRc.plugins, cliOptions), + categories: filterCategoryByOnlyPluginsOption( + importedRc.categories, + cliOptions, + ), + onlyPlugins: cliOptions.onlyPlugins, + }; return parsedProcessArgs; } + +export function filterPluginsByOnlyPluginsOption( + plugins: CoreConfig['plugins'], + { onlyPlugins }: { onlyPlugins?: string[] }, +): CoreConfig['plugins'] { + if (!onlyPlugins?.length) { + return plugins; + } + return plugins.filter(plugin => onlyPlugins.includes(plugin.slug)); +} + +// skip the whole category if it has at least one skipped plugin ref +// see https://github.com/code-pushup/cli/pull/246#discussion_r1392274281 +export function filterCategoryByOnlyPluginsOption( + categories: CoreConfig['categories'], + { onlyPlugins }: { onlyPlugins?: string[] }, +): CoreConfig['categories'] { + if (!onlyPlugins?.length) { + return categories; + } + + return categories.filter(category => + category.refs.every(ref => { + const isNotSkipped = onlyPlugins.includes(ref.slug); + + if (!isNotSkipped) { + console.log( + `${chalk.yellow('⚠')} Category "${ + category.title + }" is ignored because it references audits from skipped plugin "${ + ref.slug + }"`, + ); + } + + return isNotSkipped; + }), + ); +} + +export function validateOnlyPluginsOption( + plugins: CoreConfig['plugins'], + { onlyPlugins }: { onlyPlugins?: string[] }, +): void { + const missingPlugins = onlyPlugins?.length + ? onlyPlugins.filter(plugin => !plugins.some(({ slug }) => slug === plugin)) + : []; + + if (missingPlugins.length) { + console.log( + `${chalk.yellow( + '⚠', + )} The --onlyPlugin argument references plugins with "${missingPlugins.join( + '", "', + )}" slugs, but no such plugin is present in the configuration. Expected one of the following plugin slugs: "${plugins + .map(({ slug }) => slug) + .join('", "')}".`, + ); + } +} diff --git a/packages/cli/src/lib/implementation/filter-kebab-case-keys.spec.ts b/packages/cli/src/lib/implementation/filter-kebab-case-keys.spec.ts new file mode 100644 index 000000000..8e1236421 --- /dev/null +++ b/packages/cli/src/lib/implementation/filter-kebab-case-keys.spec.ts @@ -0,0 +1,17 @@ +import { expect } from 'vitest'; +import { filterKebabCaseKeys } from './filter-kebab-case-keys'; + +describe('filterKebabCaseKeys', () => { + it('should filter kebab-case keys', () => { + const obj = { + 'kebab-case': 'value', + camelCase: 'value', + snake_case: 'value', + }; + const filtered = filterKebabCaseKeys(obj); + expect(filtered).toEqual({ + camelCase: 'value', + snake_case: 'value', + }); + }); +}); diff --git a/packages/cli/src/lib/implementation/filter-kebab-case-keys.ts b/packages/cli/src/lib/implementation/filter-kebab-case-keys.ts new file mode 100644 index 000000000..4bbaa43ed --- /dev/null +++ b/packages/cli/src/lib/implementation/filter-kebab-case-keys.ts @@ -0,0 +1,14 @@ +export function filterKebabCaseKeys>( + obj: T, +): T { + const newObj: Record = {}; + + Object.keys(obj).forEach(key => { + if (key.includes('-')) { + return; + } + newObj[key] = obj[key]; + }); + + return newObj as T; +} diff --git a/packages/cli/src/lib/implementation/only-config-option.ts b/packages/cli/src/lib/implementation/only-config-option.ts new file mode 100644 index 000000000..ff1789d14 --- /dev/null +++ b/packages/cli/src/lib/implementation/only-config-option.ts @@ -0,0 +1,8 @@ +import { Options } from 'yargs'; + +export const onlyPluginsOption: Options = { + describe: 'List of plugins to run. If not set all plugins are run.', + type: 'array', + default: [], + coerce: (arg: string[]) => arg.flatMap(v => v.split(',')), +}; diff --git a/packages/cli/src/lib/implementation/only-plugins-options.ts b/packages/cli/src/lib/implementation/only-plugins-options.ts new file mode 100644 index 000000000..f11137632 --- /dev/null +++ b/packages/cli/src/lib/implementation/only-plugins-options.ts @@ -0,0 +1,3 @@ +export interface OnlyPluginsOptions { + onlyPlugins: string[]; +} diff --git a/packages/cli/src/lib/print-config/command-object.ts b/packages/cli/src/lib/print-config/command-object.ts index 61cd113ee..5a06e35f0 100644 --- a/packages/cli/src/lib/print-config/command-object.ts +++ b/packages/cli/src/lib/print-config/command-object.ts @@ -1,13 +1,21 @@ import { CommandModule } from 'yargs'; +import { filterKebabCaseKeys } from '../implementation/filter-kebab-case-keys'; +import { onlyPluginsOption } from '../implementation/only-config-option'; export function yargsConfigCommandObject() { const command = 'print-config'; return { command, describe: 'Print config', - handler: args => { + builder: { + onlyPlugins: onlyPluginsOption, + }, + handler: yargsArgs => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { _, $0, ...cleanArgs } = args; + const { _, $0, ...args } = yargsArgs; + // it is important to filter out kebab case keys + // because yargs duplicates options in camel case and kebab case + const cleanArgs = filterKebabCaseKeys(args); console.log(JSON.stringify(cleanArgs, null, 2)); }, } satisfies CommandModule;