diff --git a/packages/cli/src/lib/implementation/skip-plugins.middleware.ts b/packages/cli/src/lib/implementation/skip-plugins.middleware.ts new file mode 100644 index 000000000..8cc1013f9 --- /dev/null +++ b/packages/cli/src/lib/implementation/skip-plugins.middleware.ts @@ -0,0 +1,46 @@ +import { filterItemRefsBy } from '@code-pushup/utils'; +import { SkipPluginsOptions } from './skip-plugins.model'; +import { validateSkipPluginsOption } from './skip-plugins.utils'; + +export function skipPluginsMiddleware( + originalProcessArgs: T, +): T { + const { categories = [], skipPlugins: originalSkipPlugins } = + originalProcessArgs; + + if (originalSkipPlugins && originalSkipPlugins.length > 0) { + const { verbose, plugins, skipPlugins = [] } = originalProcessArgs; + + validateSkipPluginsOption( + { plugins, categories }, + { skipPlugins, verbose }, + ); + + const validSkipPlugins = skipPlugins.filter(sP => + plugins.find(p => p.slug === sP), + ); + + const skipPluginsSet = new Set(validSkipPlugins); + + return { + ...originalProcessArgs, + plugins: + skipPluginsSet.size > 0 + ? plugins.filter(({ slug }) => !skipPluginsSet.has(slug)) + : plugins, + categories: + skipPluginsSet.size > 0 + ? filterItemRefsBy( + categories, + ({ plugin }) => !skipPluginsSet.has(plugin), + ) + : categories, + }; + } + + return { + ...originalProcessArgs, + // if undefined fill categories with empty array + categories, + }; +} diff --git a/packages/cli/src/lib/implementation/skip-plugins.middleware.unit.test.ts b/packages/cli/src/lib/implementation/skip-plugins.middleware.unit.test.ts new file mode 100644 index 000000000..3c53b3137 --- /dev/null +++ b/packages/cli/src/lib/implementation/skip-plugins.middleware.unit.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, vi } from 'vitest'; +import { CategoryConfig, PluginConfig } from '@code-pushup/models'; +import { skipPluginsMiddleware } from './skip-plugins.middleware'; + +vi.mock('@code-pushup/core', async () => { + const { CORE_CONFIG_MOCK }: typeof import('@code-pushup/test-utils') = + await vi.importActual('@code-pushup/test-utils'); + const core: object = await vi.importActual('@code-pushup/core'); + return { + ...core, + readRcByPath: vi.fn().mockResolvedValue(CORE_CONFIG_MOCK), + autoloadRc: vi.fn().mockResolvedValue(CORE_CONFIG_MOCK), + }; +}); + +describe('skipPluginsMiddleware', () => { + it('should fill undefined categories with empty array', () => { + expect( + skipPluginsMiddleware({ + plugins: [{ slug: 'p1' } as PluginConfig], + }), + ).toStrictEqual({ + plugins: [{ slug: 'p1' }], + categories: [], + }); + }); + + it('should forward equal values if not set', () => { + expect( + skipPluginsMiddleware({ + plugins: [{ slug: 'p1' } as PluginConfig], + categories: [ + { slug: 'c1', refs: [{ plugin: 'p1' }] } as CategoryConfig, + ], + }), + ).toStrictEqual({ + plugins: [{ slug: 'p1' }], + categories: [{ slug: 'c1', refs: [{ plugin: 'p1' }] }], + }); + }); + + it('should filter plugins plugins for slug "p1"', () => { + const { plugins } = skipPluginsMiddleware({ + skipPlugins: ['p1'], + plugins: [{ slug: 'p1' }, { slug: 'p2' }] as PluginConfig[], + categories: [], + }); + expect(plugins).toStrictEqual([expect.objectContaining({ slug: 'p2' })]); + }); + + it('should forward plugins and categories for a slug not present in plugins', () => { + const originalCategories = [ + { + slug: 'c1', + refs: [ + { plugin: 'p1', slug: 'a1-p1' }, + { plugin: 'p2', slug: 'a2-p1' }, + ], + }, + { slug: 'c2', refs: [{ plugin: 'p2', slug: 'a1-p2' }] }, + ] as CategoryConfig[]; + const originalPlugins = [{ slug: 'p1' }, { slug: 'p2' }] as PluginConfig[]; + const { categories, plugins } = skipPluginsMiddleware({ + skipPlugins: ['wrong-slug'], + plugins: originalPlugins, + categories: originalCategories, + }); + expect(categories).toBe(originalCategories); + expect(plugins).toBe(originalPlugins); + }); + + it('should filter categories for slug "p1"', () => { + const { categories } = skipPluginsMiddleware({ + skipPlugins: ['p1'], + plugins: [{ slug: 'p1' }, { slug: 'p2' }] as PluginConfig[], + categories: [ + { + slug: 'c1', + refs: [ + { plugin: 'p1', slug: 'a1-p1' }, + { plugin: 'p2', slug: 'a2-p1' }, + ], + }, + { slug: 'c2', refs: [{ plugin: 'p2', slug: 'a1-p2' }] }, + ] as CategoryConfig[], + }); + expect(categories).toStrictEqual([ + expect.objectContaining({ + slug: 'c1', + refs: [{ plugin: 'p2', slug: 'a2-p1' }], + }), + expect.objectContaining({ + slug: 'c2', + refs: [{ plugin: 'p2', slug: 'a1-p2' }], + }), + ]); + }); +}); diff --git a/packages/cli/src/lib/implementation/skip-plugins.model.ts b/packages/cli/src/lib/implementation/skip-plugins.model.ts new file mode 100644 index 000000000..c33568044 --- /dev/null +++ b/packages/cli/src/lib/implementation/skip-plugins.model.ts @@ -0,0 +1,9 @@ +import { GlobalOptions } from '@code-pushup/core'; +import { CoreConfig } from '@code-pushup/models'; + +export type SkipPluginsCliOptions = { + skipPlugins?: string[]; +}; +export type SkipPluginsOptions = Partial & + Pick & + SkipPluginsCliOptions; diff --git a/packages/cli/src/lib/implementation/skip-plugins.options.ts b/packages/cli/src/lib/implementation/skip-plugins.options.ts new file mode 100644 index 000000000..8970e57fd --- /dev/null +++ b/packages/cli/src/lib/implementation/skip-plugins.options.ts @@ -0,0 +1,18 @@ +import { Options } from 'yargs'; +import { coerceArray } from './global.utils'; + +export const skipPluginsOption: Options = { + describe: 'List of plugins to skip. If not set all plugins are run.', + type: 'array', + default: [], + coerce: coerceArray, +}; + +export function yargsSkipPluginsOptionsDefinition(): Record< + 'skipPlugins', + Options +> { + return { + skipPlugins: skipPluginsOption, + }; +} diff --git a/packages/cli/src/lib/implementation/skip-plugins.utils.ts b/packages/cli/src/lib/implementation/skip-plugins.utils.ts new file mode 100644 index 000000000..0643f5719 --- /dev/null +++ b/packages/cli/src/lib/implementation/skip-plugins.utils.ts @@ -0,0 +1,46 @@ +import chalk from 'chalk'; +import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; +import { filterItemRefsBy, ui } from '@code-pushup/utils'; + +export function validateSkipPluginsOption( + { + plugins, + categories, + }: { + plugins: PluginConfig[]; + categories: CategoryConfig[]; + }, + { + skipPlugins = [], + verbose = false, + }: { skipPlugins?: string[]; verbose?: boolean } = {}, +): void { + const skipPluginsSet = new Set(skipPlugins); + const missingPlugins = skipPlugins.filter( + plugin => !plugins.some(({ slug }) => slug === plugin), + ); + + if (missingPlugins.length > 0 && verbose) { + ui().logger.info( + `${chalk.yellow( + '⚠', + )} The --skipPlugin argument references plugins with "${missingPlugins.join( + '", "', + )}" slugs, but no such plugins are present in the configuration. Expected one of the following plugin slugs: "${plugins + .map(({ slug }) => slug) + .join('", "')}".`, + ); + } + + if (categories.length > 0) { + const removedCategorieSlugs = filterItemRefsBy(categories, ({ plugin }) => + skipPluginsSet.has(plugin), + ).map(({ slug }) => slug); + ui().logger.info( + `The --skipPlugin argument removed categories with "${removedCategorieSlugs.join( + '", "', + )}" slugs. + `, + ); + } +} diff --git a/packages/cli/src/lib/implementation/skip-plugins.utils.unit.test.ts b/packages/cli/src/lib/implementation/skip-plugins.utils.unit.test.ts new file mode 100644 index 000000000..ec9b49918 --- /dev/null +++ b/packages/cli/src/lib/implementation/skip-plugins.utils.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect } from 'vitest'; +import { CategoryConfig, PluginConfig } from '@code-pushup/models'; +import { getLogMessages } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; +import { validateSkipPluginsOption } from './skip-plugins.utils'; + +describe('validateSkipPluginsOption', () => { + it('should warn if skipPlugins option contains non-existing plugin', () => { + validateSkipPluginsOption( + { + plugins: [ + { slug: 'plugin1', audits: [{ slug: 'a1' }] }, + ] as PluginConfig[], + categories: [], + }, + { + skipPlugins: ['plugin1', 'plugin3', 'plugin4'], + verbose: true, + }, + ); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toContain( + 'The --skipPlugin argument references plugins with "plugin3", "plugin4" slugs', + ); + }); + + it('should not log if skipPlugins option contains only existing plugins', () => { + validateSkipPluginsOption( + { + plugins: [ + { slug: 'plugin1', audits: [{ slug: 'a1-p1' }] }, + { slug: 'plugin2', audits: [{ slug: 'a1-p2' }] }, + ] as PluginConfig[], + categories: [], + }, + { + skipPlugins: ['plugin1'], + verbose: true, + }, + ); + expect(getLogMessages(ui().logger)).toHaveLength(0); + }); + + it('should print ignored category and its first violating plugin', () => { + validateSkipPluginsOption( + { + plugins: [ + { slug: 'plugin1', audits: [{ slug: 'a1-p1' }] }, + { slug: 'plugin2', audits: [{ slug: 'a1-p2' }] }, + ] as PluginConfig[], + categories: [ + { slug: 'c1', refs: [{ plugin: 'plugin2' }] } as CategoryConfig, + { slug: 'c2', refs: [{ plugin: 'plugin1' }] } as CategoryConfig, + { slug: 'c3', refs: [{ plugin: 'plugin2' }] } as CategoryConfig, + ], + }, + { + skipPlugins: ['plugin2'], + verbose: true, + }, + ); + console.log(getLogMessages(ui().logger)); + expect(getLogMessages(ui().logger)).toHaveLength(1); + expect(getLogMessages(ui().logger)[0]).toContain( + 'The --skipPlugin argument removed categories with "c1", "c3" slugs', + ); + }); +});