diff --git a/packages/cli/src/lib/implementation/filter.middleware.ts b/packages/cli/src/lib/implementation/filter.middleware.ts index 92f380f2b..b0c1349b5 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.ts @@ -1,18 +1,31 @@ -import type { CoreConfig } from '@code-pushup/models'; +import type { + CategoryConfig, + CoreConfig, + PluginConfig, +} from '@code-pushup/models'; import { filterItemRefsBy } from '@code-pushup/utils'; +import { + applyFilters, + extractSkippedItems, + filterPluginsFromCategories, + filterSkippedItems, + isValidCategoryRef, +} from './filter.middleware.utils.js'; import type { FilterOptions, Filterables } from './filter.model.js'; import { handleConflictingOptions, validateFilterOption, validateFinalState, + validateSkippedCategories, } from './validate-filter-options.utils.js'; +// eslint-disable-next-line max-lines-per-function export function filterMiddleware( originalProcessArgs: T, ): T { const { - plugins, - categories, + categories: rcCategories, + plugins: rcPlugins, skipCategories = [], onlyCategories = [], skipPlugins = [], @@ -20,29 +33,40 @@ export function filterMiddleware( verbose = false, } = originalProcessArgs; + const plugins = filterSkippedInPlugins(rcPlugins); + const categories = filterSkippedCategories(rcCategories, plugins); + if ( skipCategories.length === 0 && onlyCategories.length === 0 && skipPlugins.length === 0 && onlyPlugins.length === 0 ) { - return originalProcessArgs; + if (rcCategories && categories) { + validateSkippedCategories(rcCategories, categories, verbose); + } + return { + ...originalProcessArgs, + ...(categories && { categories }), + plugins, + }; } handleConflictingOptions('categories', onlyCategories, skipCategories); handleConflictingOptions('plugins', onlyPlugins, skipPlugins); + const skippedPlugins = extractSkippedItems(rcPlugins, plugins); + const skippedCategories = extractSkippedItems(rcCategories, categories); + const filteredCategories = applyCategoryFilters( { categories, plugins }, - skipCategories, - onlyCategories, - verbose, + skippedCategories, + { skipCategories, onlyCategories, verbose }, ); const filteredPlugins = applyPluginFilters( { categories: filteredCategories, plugins }, - skipPlugins, - onlyPlugins, - verbose, + skippedPlugins, + { skipPlugins, onlyPlugins, verbose }, ); const finalCategories = filteredCategories ? filterItemRefsBy(filteredCategories, ref => @@ -52,7 +76,7 @@ export function filterMiddleware( validateFinalState( { categories: finalCategories, plugins: filteredPlugins }, - { categories, plugins }, + { categories: rcCategories, plugins: rcPlugins }, ); return { @@ -62,53 +86,37 @@ export function filterMiddleware( }; } -function applyFilters( - items: T[], - skipItems: string[], - onlyItems: string[], - key: keyof T, -): T[] { - return items.filter(item => { - const itemKey = item[key] as unknown as string; - return ( - !skipItems.includes(itemKey) && - (onlyItems.length === 0 || onlyItems.includes(itemKey)) - ); - }); -} - function applyCategoryFilters( { categories, plugins }: Filterables, - skipCategories: string[], - onlyCategories: string[], - verbose: boolean, + skippedCategories: string[], + options: Pick, ): CoreConfig['categories'] { + const { skipCategories = [], onlyCategories = [], verbose = false } = options; if ( (skipCategories.length === 0 && onlyCategories.length === 0) || - !categories || - categories.length === 0 + ((!categories || categories.length === 0) && skippedCategories.length === 0) ) { return categories; } validateFilterOption( 'skipCategories', { plugins, categories }, - { itemsToFilter: skipCategories, verbose }, + { itemsToFilter: skipCategories, skippedItems: skippedCategories, verbose }, ); validateFilterOption( 'onlyCategories', { plugins, categories }, - { itemsToFilter: onlyCategories, verbose }, + { itemsToFilter: onlyCategories, skippedItems: skippedCategories, verbose }, ); - return applyFilters(categories, skipCategories, onlyCategories, 'slug'); + return applyFilters(categories ?? [], skipCategories, onlyCategories, 'slug'); } function applyPluginFilters( { categories, plugins }: Filterables, - skipPlugins: string[], - onlyPlugins: string[], - verbose: boolean, + skippedPlugins: string[], + options: Pick, ): CoreConfig['plugins'] { + const { skipPlugins = [], onlyPlugins = [], verbose = false } = options; const filteredPlugins = filterPluginsFromCategories({ categories, plugins, @@ -119,25 +127,43 @@ function applyPluginFilters( validateFilterOption( 'skipPlugins', { plugins: filteredPlugins, categories }, - { itemsToFilter: skipPlugins, verbose }, + { itemsToFilter: skipPlugins, skippedItems: skippedPlugins, verbose }, ); validateFilterOption( 'onlyPlugins', { plugins: filteredPlugins, categories }, - { itemsToFilter: onlyPlugins, verbose }, + { itemsToFilter: onlyPlugins, skippedItems: skippedPlugins, verbose }, ); return applyFilters(filteredPlugins, skipPlugins, onlyPlugins, 'slug'); } -function filterPluginsFromCategories({ - categories, - plugins, -}: Filterables): CoreConfig['plugins'] { - if (!categories || categories.length === 0) { - return plugins; - } - const validPluginSlugs = new Set( - categories.flatMap(category => category.refs.map(ref => ref.plugin)), - ); - return plugins.filter(plugin => validPluginSlugs.has(plugin.slug)); +export function filterSkippedInPlugins( + plugins: PluginConfig[], +): PluginConfig[] { + return plugins.map((plugin: PluginConfig) => { + const filteredAudits = filterSkippedItems(plugin.audits); + return { + ...plugin, + ...(plugin.groups && { + groups: filterItemRefsBy(filterSkippedItems(plugin.groups), ref => + filteredAudits.some(({ slug }) => slug === ref.slug), + ), + }), + audits: filteredAudits, + }; + }); +} + +export function filterSkippedCategories( + categories: CoreConfig['categories'], + plugins: CoreConfig['plugins'], +): CoreConfig['categories'] { + return categories + ?.map(category => { + const validRefs = category.refs.filter(ref => + isValidCategoryRef(ref, plugins), + ); + return validRefs.length > 0 ? { ...category, refs: validRefs } : null; + }) + .filter((category): category is CategoryConfig => category != null); } diff --git a/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts b/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts index 5a8ad6a03..ecc5de17f 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts @@ -1,6 +1,10 @@ import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; import { ui } from '@code-pushup/utils'; -import { filterMiddleware } from './filter.middleware.js'; +import { + filterMiddleware, + filterSkippedCategories, + filterSkippedInPlugins, +} from './filter.middleware.js'; import { OptionValidationError } from './validate-filter-options.utils.js'; vi.mock('@code-pushup/core', async () => { @@ -34,8 +38,8 @@ describe('filterMiddleware', () => { { slug: 'c1', refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, + { type: 'audit', plugin: 'p1', slug: 'a1-p1' }, + { type: 'audit', plugin: 'p2', slug: 'a1-p2' }, ], }, ] as CategoryConfig[]; @@ -58,8 +62,8 @@ describe('filterMiddleware', () => { { slug: 'c1', refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, + { type: 'audit', plugin: 'p1', slug: 'a1-p1' }, + { type: 'audit', plugin: 'p2', slug: 'a1-p2' }, ], }, ] as CategoryConfig[]; @@ -87,149 +91,227 @@ describe('filterMiddleware', () => { (option, expected) => { const { plugins } = filterMiddleware({ ...option, - plugins: [{ slug: 'p1' }, { slug: 'p2' }] as PluginConfig[], + plugins: [ + { + slug: 'p1', + groups: [{ slug: 'g1-p1', refs: [{ slug: 'a1-p1', weight: 1 }] }], + audits: [{ slug: 'a1-p1' }], + }, + { + slug: 'p2', + groups: [{ slug: 'g1-p2', refs: [{ slug: 'a1-p2', weight: 1 }] }], + audits: [{ slug: 'a1-p2' }], + }, + ] as PluginConfig[], }); expect(plugins).toStrictEqual([expect.objectContaining(expected)]); }, ); it.each([ - [ - { onlyPlugins: ['p1'] }, - [{ slug: 'p1' }], - [{ slug: 'c1', refs: [{ plugin: 'p1', slug: 'a1-p1' }] }], - ], - [ - { skipPlugins: ['p1'], onlyPlugins: ['p3'] }, - [{ slug: 'p3' }], - [{ slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }], - ], - [ - { skipPlugins: ['p1'] }, - [{ slug: 'p2' }, { slug: 'p3' }], - [ - { slug: 'c1', refs: [{ plugin: 'p2', slug: 'a1-p2' }] }, - { slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }, - ], - ], + [{ onlyPlugins: ['p1'] }, ['p1'], ['c1']], + [{ skipPlugins: ['p1'], onlyPlugins: ['p3'] }, ['p3'], ['c2']], + [{ skipPlugins: ['p1'] }, ['p2', 'p3'], ['c1', 'c2']], ])( 'should filter plugins and categories with plugin filter option %o', (option, expectedPlugins, expectedCategories) => { const { plugins, categories } = filterMiddleware({ ...option, plugins: [ - { slug: 'p1' }, - { slug: 'p2' }, - { slug: 'p3' }, + { + slug: 'p1', + audits: [{ slug: 'a1-p1' }], + groups: [{ slug: 'g1-p1', refs: [{ slug: 'a1-p1', weight: 1 }] }], + }, + { + slug: 'p2', + audits: [{ slug: 'a1-p2' }], + groups: [{ slug: 'g1-p2', refs: [{ slug: 'a1-p2', weight: 1 }] }], + }, + { + slug: 'p3', + audits: [{ slug: 'a1-p3' }], + groups: [{ slug: 'g1-p3', refs: [{ slug: 'a1-p3', weight: 1 }] }], + }, ] as PluginConfig[], categories: [ { slug: 'c1', refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, + { type: 'group', plugin: 'p1', slug: 'g1-p1', weight: 1 }, + { type: 'group', plugin: 'p2', slug: 'g1-p2', weight: 1 }, ], }, - { slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }, + { + slug: 'c2', + refs: [{ type: 'group', plugin: 'p3', slug: 'g1-p3', weight: 1 }], + }, ] as CategoryConfig[], }); - expect(plugins).toStrictEqual(expectedPlugins); - expect(categories).toStrictEqual(expectedCategories); + const pluginSlugs = plugins.map(({ slug }) => slug); + const categorySlugs = categories.map(({ slug }) => slug); + + expect(pluginSlugs).toStrictEqual(expectedPlugins); + expect(categorySlugs).toStrictEqual(expectedCategories); + }, + ); + + it.each([ + [{ skipCategories: ['c1'] }, ['p3'], ['c2']], + [{ skipCategories: ['c1'], onlyCategories: ['c2'] }, ['p3'], ['c2']], + [{ onlyCategories: ['c1'] }, ['p1', 'p2'], ['c1']], + ])( + 'should filter plugins and categories with category filter option %o', + (option, expectedPlugins, expectedCategories) => { + const { plugins, categories } = filterMiddleware({ + ...option, + plugins: [ + { + slug: 'p1', + audits: [{ slug: 'a1-p1' }], + groups: [{ slug: 'g1-p1', refs: [{ slug: 'a1-p1', weight: 1 }] }], + }, + { + slug: 'p2', + audits: [{ slug: 'a1-p2' }], + groups: [{ slug: 'g1-p2', refs: [{ slug: 'a1-p2', weight: 1 }] }], + }, + { + slug: 'p3', + audits: [{ slug: 'a1-p3' }], + groups: [{ slug: 'g1-p3', refs: [{ slug: 'a1-p3', weight: 1 }] }], + }, + ] as PluginConfig[], + categories: [ + { + slug: 'c1', + refs: [ + { type: 'group', plugin: 'p1', slug: 'g1-p1', weight: 1 }, + { type: 'group', plugin: 'p2', slug: 'g1-p2', weight: 1 }, + ], + }, + { + slug: 'c2', + refs: [{ type: 'group', plugin: 'p3', slug: 'g1-p3', weight: 1 }], + }, + ] as CategoryConfig[], + }); + const pluginSlugs = plugins.map(({ slug }) => slug); + const categorySlugs = categories.map(({ slug }) => slug); + + expect(pluginSlugs).toStrictEqual(expectedPlugins); + expect(categorySlugs).toStrictEqual(expectedCategories); }, ); it.each([ [ - { skipCategories: ['c1'] }, - [{ slug: 'p3' }], - [{ slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }], - ], - [ - { skipCategories: ['c1'], onlyCategories: ['c2'] }, - [{ slug: 'p3' }], - [{ slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }], + { skipPlugins: ['eslint'], onlyCategories: ['performance'] }, + ['lighthouse'], + ['performance'], ], [ - { onlyCategories: ['c1'] }, - [{ slug: 'p1' }, { slug: 'p2' }], - [ - { - slug: 'c1', - refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, - ], - }, - ], + { skipCategories: ['performance'], onlyPlugins: ['lighthouse'] }, + ['lighthouse'], + ['best-practices'], ], ])( - 'should filter plugins and categories with category filter option %o', + 'should filter plugins and categories with mixed filter options: %o', (option, expectedPlugins, expectedCategories) => { const { plugins, categories } = filterMiddleware({ ...option, plugins: [ - { slug: 'p1' }, - { slug: 'p2' }, - { slug: 'p3' }, + { + slug: 'lighthouse', + audits: [{ slug: 'largest-contentful-paint' }, { slug: 'doctype' }], + groups: [ + { + slug: 'performance', + refs: [{ slug: 'largest-contentful-paint', weight: 1 }], + }, + { + slug: 'best-practices', + refs: [{ slug: 'doctype', weight: 1 }], + }, + ], + }, + { + slug: 'eslint', + audits: [{ slug: 'no-unreachable' }], + groups: [ + { + slug: 'problems', + refs: [{ slug: 'no-unreachable', weight: 1 }], + }, + ], + }, ] as PluginConfig[], categories: [ { - slug: 'c1', + slug: 'performance', + refs: [ + { + type: 'group', + plugin: 'lighthouse', + slug: 'performance', + weight: 1, + }, + ], + }, + { + slug: 'best-practices', refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, + { + type: 'group', + plugin: 'lighthouse', + slug: 'best-practices', + weight: 1, + }, + ], + }, + { + slug: 'bug-prevention', + refs: [ + { + type: 'group', + plugin: 'eslint', + slug: 'problems', + weight: 1, + }, ], }, - { slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }, ] as CategoryConfig[], }); - expect(plugins).toStrictEqual(expectedPlugins); - expect(categories).toStrictEqual(expectedCategories); + const pluginSlugs = plugins.map(({ slug }) => slug); + const categorySlugs = categories?.map(({ slug }) => slug); + + expect(pluginSlugs).toStrictEqual(expectedPlugins); + expect(categorySlugs).toStrictEqual(expectedCategories); }, ); - it('should filter plugins and categories with mixed filter options', () => { - const { plugins, categories } = filterMiddleware({ - skipPlugins: ['p1'], - onlyCategories: ['c1'], - plugins: [ - { slug: 'p1' }, - { slug: 'p2' }, - { slug: 'p3' }, - ] as PluginConfig[], - categories: [ - { - slug: 'c1', - refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, - ], - }, - { slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }, - ] as CategoryConfig[], - }); - expect(plugins).toStrictEqual([{ slug: 'p2' }]); - expect(categories).toStrictEqual([ - { slug: 'c1', refs: [{ plugin: 'p2', slug: 'a1-p2' }] }, - ]); - }); - it('should trigger verbose logging when skipPlugins or onlyPlugins removes categories', () => { const loggerSpy = vi.spyOn(ui().logger, 'info'); filterMiddleware({ onlyPlugins: ['p1'], skipPlugins: ['p2'], - plugins: [{ slug: 'p1' }, { slug: 'p2' }] as PluginConfig[], + plugins: [ + { slug: 'p1', audits: [{ slug: 'a1-p1' }] }, + { slug: 'p2', audits: [{ slug: 'a1-p2' }] }, + ] as PluginConfig[], categories: [ { slug: 'c1', refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, + { type: 'audit', plugin: 'p1', slug: 'a1-p1', weight: 1 }, + { type: 'audit', plugin: 'p2', slug: 'a1-p2', weight: 1 }, ], }, - { slug: 'c2', refs: [{ plugin: 'p2', slug: 'a1-p2' }] }, + { + slug: 'c2', + refs: [{ type: 'audit', plugin: 'p2', slug: 'a1-p2', weight: 1 }], + }, ] as CategoryConfig[], verbose: true, }); @@ -251,11 +333,14 @@ describe('filterMiddleware', () => { { slug: 'c1', refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, + { type: 'audit', plugin: 'p1', slug: 'a1-p1', weight: 1 }, + { type: 'audit', plugin: 'p2', slug: 'a1-p2', weight: 1 }, ], }, - { slug: 'c2', refs: [{ plugin: 'p2', slug: 'a1-p2' }] }, + { + slug: 'c2', + refs: [{ type: 'audit', plugin: 'p2', slug: 'a1-p2', weight: 1 }], + }, ] as CategoryConfig[], }), ).toThrow( @@ -276,8 +361,8 @@ describe('filterMiddleware', () => { { slug: 'c1', refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, + { type: 'audit', plugin: 'p1', slug: 'a1-p1', weight: 1 }, + { type: 'audit', plugin: 'p2', slug: 'a1-p2', weight: 1 }, ], }, ] as CategoryConfig[], @@ -295,18 +380,156 @@ describe('filterMiddleware', () => { expect(() => { filterMiddleware({ plugins: [ - { slug: 'p1', audits: [{ slug: 'a1-p1' }] }, + { + slug: 'p1', + audits: [{ slug: 'a1-p1', isSkipped: false }], + }, + { + slug: 'p2', + groups: [{ slug: 'g1-p2', isSkipped: true }], + }, ] as PluginConfig[], categories: [ - { slug: 'c1', refs: [{ plugin: 'p1', slug: 'a1-p1' }] }, + { + slug: 'c1', + refs: [{ type: 'audit', plugin: 'p1', slug: 'a1-p1', weight: 1 }], + }, + { + slug: 'c2', + refs: [{ type: 'group', plugin: 'p1', slug: 'g1-p2', weight: 1 }], + }, ] as CategoryConfig[], skipPlugins: ['p1'], onlyCategories: ['c1'], }); }).toThrow( new OptionValidationError( - `Nothing to report. No plugins or categories are available after filtering. Available plugins: p1. Available categories: c1.`, + `Nothing to report. No plugins or categories are available after filtering. Available plugins: p1, p2. Available categories: c1, c2.`, ), ); }); }); + +describe('filterSkippedInPlugins', () => { + it('should filter out skipped audits and groups', () => { + expect( + filterSkippedInPlugins([ + { + slug: 'p1', + audits: [ + { slug: 'a1', isSkipped: false }, + { slug: 'a2', isSkipped: true }, + ], + groups: [ + { + slug: 'g1', + refs: [ + { slug: 'a1', weight: 1 }, + { slug: 'a2', weight: 1 }, + ], + isSkipped: false, + }, + ], + }, + ] as PluginConfig[]), + ).toEqual([ + { + slug: 'p1', + audits: [{ slug: 'a1' }], + groups: [ + { + slug: 'g1', + refs: [{ slug: 'a1', weight: 1 }], + }, + ], + }, + ]); + }); + + it('should filter out entire groups when marked as skipped', () => { + expect( + filterSkippedInPlugins([ + { + slug: 'p1', + audits: [{ slug: 'a1', isSkipped: false }], + groups: [ + { + slug: 'g1', + refs: [{ slug: 'a1', weight: 1 }], + isSkipped: true, + }, + ], + }, + ] as PluginConfig[]), + ).toEqual([ + { + slug: 'p1', + audits: [{ slug: 'a1' }], + groups: [], + }, + ]); + }); +}); + +describe('filterSkippedCategories', () => { + it('should filter out categories with all skipped refs', () => { + expect( + filterSkippedCategories( + [ + { + slug: 'c1', + refs: [{ type: 'group', plugin: 'p1', slug: 'g1', weight: 1 }], + }, + ] as CategoryConfig[], + [ + { + slug: 'p1', + audits: [{ slug: 'a1' }, { slug: 'a2' }], + }, + ] as PluginConfig[], + ), + ).toStrictEqual([]); + }); + + it('should retain categories with valid refs', () => { + expect( + filterSkippedCategories( + [ + { + slug: 'c1', + refs: [{ type: 'group', plugin: 'p1', slug: 'g1-p1', weight: 1 }], + }, + { + slug: 'c2', + refs: [{ type: 'group', plugin: 'p2', slug: 'g1-p2', weight: 1 }], + }, + ] as CategoryConfig[], + [ + { + slug: 'p1', + audits: [{ slug: 'a1-p1' }, { slug: 'a2-p1' }], + groups: [], + }, + { + slug: 'p2', + audits: [{ slug: 'a1-p2' }, { slug: 'a2-p2' }], + groups: [ + { + slug: 'g1-p2', + refs: [ + { slug: 'a1-p2', weight: 1 }, + { slug: 'a2-p2', weight: 1 }, + ], + }, + ], + }, + ] as PluginConfig[], + ), + ).toEqual([ + { + slug: 'c2', + refs: [{ type: 'group', plugin: 'p2', slug: 'g1-p2', weight: 1 }], + }, + ]); + }); +}); diff --git a/packages/cli/src/lib/implementation/filter.middleware.utils.ts b/packages/cli/src/lib/implementation/filter.middleware.utils.ts new file mode 100644 index 000000000..f27065d4e --- /dev/null +++ b/packages/cli/src/lib/implementation/filter.middleware.utils.ts @@ -0,0 +1,67 @@ +import type { CategoryRef, CoreConfig } from '@code-pushup/models'; +import type { Filterables } from './filter.model'; + +export function applyFilters( + items: T[], + skipItems: string[], + onlyItems: string[], + key: keyof T, +): T[] { + return items.filter(item => { + const itemKey = item[key] as unknown as string; + return ( + !skipItems.includes(itemKey) && + (onlyItems.length === 0 || onlyItems.includes(itemKey)) + ); + }); +} + +export function extractSkippedItems( + originalItems: T[] | undefined, + filteredItems: T[] | undefined, +): string[] { + if (!originalItems || !filteredItems) { + return []; + } + const filteredSlugs = new Set(filteredItems.map(({ slug }) => slug)); + return originalItems + .filter(({ slug }) => !filteredSlugs.has(slug)) + .map(({ slug }) => slug); +} + +export function filterSkippedItems( + items: T[] | undefined, +): Omit[] { + return (items ?? []) + .filter(({ isSkipped }) => isSkipped !== true) + .map(({ isSkipped, ...props }) => props); +} + +export function isValidCategoryRef( + ref: CategoryRef, + plugins: Filterables['plugins'], +): boolean { + const plugin = plugins.find(({ slug }) => slug === ref.plugin); + if (!plugin) { + return false; + } + switch (ref.type) { + case 'audit': + return plugin.audits.some(({ slug }) => slug === ref.slug); + case 'group': + return plugin.groups?.some(({ slug }) => slug === ref.slug) ?? false; + } +} + +export function filterPluginsFromCategories({ + categories, + plugins, +}: Filterables): CoreConfig['plugins'] { + if (!categories || categories.length === 0) { + return plugins; + } + const validPluginSlugs = new Set( + categories.flatMap(category => category.refs.map(ref => ref.plugin)), + ); + return plugins.filter(plugin => validPluginSlugs.has(plugin.slug)); +} diff --git a/packages/cli/src/lib/implementation/filter.middleware.utils.unit.test.ts b/packages/cli/src/lib/implementation/filter.middleware.utils.unit.test.ts new file mode 100644 index 000000000..692b5c689 --- /dev/null +++ b/packages/cli/src/lib/implementation/filter.middleware.utils.unit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect } from 'vitest'; +import type { CategoryRef } from '@code-pushup/models'; +import { + extractSkippedItems, + isValidCategoryRef, +} from './filter.middleware.utils'; +import type { Filterables } from './filter.model'; + +describe('isValidCategoryRef', () => { + const plugins = [ + { + slug: 'p1', + audits: [{ slug: 'a1' }], + groups: [{ slug: 'g1' }], + }, + ] as Filterables['plugins']; + + it('should return true for valid audit ref', () => { + const ref = { + type: 'audit', + slug: 'a1', + plugin: 'p1', + weight: 1, + } satisfies CategoryRef; + expect(isValidCategoryRef(ref, plugins)).toBe(true); + }); + + it('should return false for skipped audit ref', () => { + const ref = { + type: 'audit', + slug: 'a2', + plugin: 'p1', + weight: 1, + } satisfies CategoryRef; + expect(isValidCategoryRef(ref, plugins)).toBe(false); + }); + + it('should return true for valid group ref', () => { + const ref = { + type: 'group', + slug: 'g1', + plugin: 'p1', + weight: 1, + } satisfies CategoryRef; + expect(isValidCategoryRef(ref, plugins)).toBe(true); + }); + + it('should return false for skipped group ref', () => { + const ref = { + type: 'group', + slug: 'g2', + plugin: 'p1', + weight: 1, + } satisfies CategoryRef; + expect(isValidCategoryRef(ref, plugins)).toBe(false); + }); + + it('should return false for nonexistent plugin', () => { + const ref = { + type: 'audit', + slug: 'a1', + plugin: 'nonexistent', + weight: 1, + } satisfies CategoryRef; + expect(isValidCategoryRef(ref, plugins)).toBe(false); + }); +}); + +describe('extractSkippedItems', () => { + it('should extract skipped items', () => { + expect( + extractSkippedItems( + [{ slug: 'p1' }, { slug: 'p2' }, { slug: 'p3' }, { slug: 'p4' }], + [{ slug: 'p1' }, { slug: 'p2' }, { slug: 'p3' }], + ), + ).toStrictEqual(['p4']); + }); +}); diff --git a/packages/cli/src/lib/implementation/validate-filter-options.utils.ts b/packages/cli/src/lib/implementation/validate-filter-options.utils.ts index c84b4af03..60048f0a6 100644 --- a/packages/cli/src/lib/implementation/validate-filter-options.utils.ts +++ b/packages/cli/src/lib/implementation/validate-filter-options.utils.ts @@ -1,4 +1,4 @@ -import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; +import type { PluginConfig } from '@code-pushup/models'; import { capitalize, filterItemRefsBy, @@ -9,45 +9,65 @@ import type { FilterOptionType, Filterables } from './filter.model.js'; export class OptionValidationError extends Error {} +// eslint-disable-next-line max-lines-per-function export function validateFilterOption( option: FilterOptionType, { plugins, categories = [] }: Filterables, - { itemsToFilter, verbose }: { itemsToFilter: string[]; verbose: boolean }, + { + itemsToFilter, + skippedItems, + verbose, + }: { itemsToFilter: string[]; skippedItems: string[]; verbose: boolean }, ): void { - const itemsToFilterSet = new Set(itemsToFilter); const validItems = isCategoryOption(option) - ? categories + ? categories.map(({ slug }) => slug) : isPluginOption(option) - ? plugins + ? plugins.map(({ slug }) => slug) : []; - const invalidItems = itemsToFilter.filter( - item => !validItems.some(({ slug }) => slug === item), - ); - const message = createValidationMessage(option, invalidItems, validItems); + const itemsToFilterSet = new Set(itemsToFilter); + const skippedItemsSet = new Set(skippedItems); + const validItemsSet = new Set(validItems); - if ( - isOnlyOption(option) && - itemsToFilterSet.size > 0 && - itemsToFilterSet.size === invalidItems.length - ) { - throw new OptionValidationError(message); - } + const nonExistentItems = itemsToFilter.filter( + item => !validItemsSet.has(item) && !skippedItemsSet.has(item), + ); + const skippedValidItems = itemsToFilter.filter(item => + skippedItemsSet.has(item), + ); - if (invalidItems.length > 0) { + if (nonExistentItems.length > 0) { + const message = createValidationMessage( + option, + nonExistentItems, + validItemsSet, + ); + if ( + isOnlyOption(option) && + itemsToFilterSet.size > 0 && + itemsToFilterSet.size === nonExistentItems.length + ) { + throw new OptionValidationError(message); + } ui().logger.warning(message); } - + if (skippedValidItems.length > 0 && verbose) { + const item = getItemType(option, skippedValidItems.length); + const prefix = skippedValidItems.length === 1 ? `a skipped` : `skipped`; + ui().logger.warning( + `The --${option} argument references ${prefix} ${item}: ${skippedValidItems.join(', ')}.`, + ); + } if (isPluginOption(option) && categories.length > 0 && verbose) { - const removedCategorySlugs = filterItemRefsBy(categories, ({ plugin }) => + const removedCategories = filterItemRefsBy(categories, ({ plugin }) => isOnlyOption(option) ? !itemsToFilterSet.has(plugin) : itemsToFilterSet.has(plugin), ).map(({ slug }) => slug); - if (removedCategorySlugs.length > 0) { + if (removedCategories.length > 0) { ui().logger.info( - `The --${option} argument removed the following categories: ${removedCategorySlugs.join( + `The --${option} argument removed the following categories: ${removedCategories.join( ', ', )}.`, ); @@ -55,6 +75,32 @@ export function validateFilterOption( } } +export function validateSkippedCategories( + originalCategories: NonNullable, + filteredCategories: NonNullable, + verbose: boolean, +): void { + const skippedCategories = originalCategories.filter( + original => !filteredCategories.some(({ slug }) => slug === original.slug), + ); + if (skippedCategories.length > 0 && verbose) { + skippedCategories.forEach(category => { + ui().logger.info( + `Category ${category.slug} was removed because all its refs were skipped. Affected refs: ${category.refs + .map(ref => `${ref.slug} (${ref.type})`) + .join(', ')}`, + ); + }); + } + if (filteredCategories.length === 0) { + throw new OptionValidationError( + `No categories remain after filtering. Removed categories: ${skippedCategories + .map(({ slug }) => slug) + .join(', ')}`, + ); + } +} + export function validateFinalState( filteredItems: Filterables, originalItems: Filterables, @@ -76,6 +122,22 @@ export function validateFinalState( `Nothing to report. No plugins or categories are available after filtering. Available plugins: ${availablePlugins}. Available categories: ${availableCategories}.`, ); } + if (filteredPlugins.some(pluginHasZeroWeightRefs)) { + throw new OptionValidationError( + 'Some groups in the filtered plugins have only zero-weight references. Please adjust your filters or weights.', + ); + } +} + +export function pluginHasZeroWeightRefs( + plugin: Pick, +): boolean { + if (!plugin.groups || plugin.groups.length === 0) { + return false; + } + return plugin.groups.some( + group => group.refs.reduce((sum, ref) => sum + ref.weight, 0) === 0, + ); } function isCategoryOption(option: FilterOptionType): boolean { @@ -102,7 +164,7 @@ export function getItemType(option: FilterOptionType, count: number): string { export function createValidationMessage( option: FilterOptionType, invalidItems: string[], - validItems: Pick[], + validItems: Set, ): string { const invalidItem = getItemType(option, invalidItems.length); const invalidItemText = @@ -111,12 +173,12 @@ export function createValidationMessage( : `${invalidItem} that do not exist:`; const invalidSlugs = invalidItems.join(', '); - const validItem = getItemType(option, validItems.length); + const validItem = getItemType(option, validItems.size); const validItemText = - validItems.length === 1 + validItems.size === 1 ? `The only valid ${validItem} is` : `Valid ${validItem} are`; - const validSlugs = validItems.map(({ slug }) => slug).join(', '); + const validSlugs = [...validItems].join(', '); return `The --${option} argument references ${invalidItemText} ${invalidSlugs}. ${validItemText} ${validSlugs}.`; } diff --git a/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts b/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts index 770ffd520..4bee2b3c2 100644 --- a/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts +++ b/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts @@ -2,14 +2,16 @@ import { describe, expect } from 'vitest'; import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; -import type { FilterOptionType } from './filter.model.js'; +import type { FilterOptionType, Filterables } from './filter.model.js'; import { OptionValidationError, createValidationMessage, getItemType, handleConflictingOptions, + pluginHasZeroWeightRefs, validateFilterOption, validateFinalState, + validateSkippedCategories, } from './validate-filter-options.utils.js'; describe('validateFilterOption', () => { @@ -47,7 +49,7 @@ describe('validateFilterOption', () => { { slug: 'c1', refs: [{ plugin: 'p1', slug: 'a1-p1' }] }, ] as CategoryConfig[], }, - { itemsToFilter, verbose: false }, + { itemsToFilter, skippedItems: [], verbose: false }, ); const logs = getLogMessages(ui().logger); expect(logs[0]).toContain(expected); @@ -91,7 +93,7 @@ describe('validateFilterOption', () => { }, ] as CategoryConfig[], }, - { itemsToFilter, verbose: false }, + { itemsToFilter, skippedItems: [], verbose: false }, ); const logs = getLogMessages(ui().logger); expect(logs[0]).toContain(expected); @@ -107,7 +109,7 @@ describe('validateFilterOption', () => { { slug: 'p2', audits: [{ slug: 'a1-p2' }] }, ] as PluginConfig[], }, - { itemsToFilter: ['p1'], verbose: false }, + { itemsToFilter: ['p1'], skippedItems: [], verbose: false }, ); expect(getLogMessages(ui().logger)).toHaveLength(0); }); @@ -126,7 +128,7 @@ describe('validateFilterOption', () => { { slug: 'c3', refs: [{ plugin: 'p2' }] }, ] as CategoryConfig[], }, - { itemsToFilter: ['p1'], verbose: true }, + { itemsToFilter: ['p1'], skippedItems: [], verbose: true }, ); expect(getLogMessages(ui().logger)).toHaveLength(1); expect(getLogMessages(ui().logger)[0]).toContain( @@ -145,7 +147,7 @@ describe('validateFilterOption', () => { { slug: 'p3', audits: [{ slug: 'a1-p3' }] }, ] as PluginConfig[], }, - { itemsToFilter: ['p4', 'p5'], verbose: false }, + { itemsToFilter: ['p4', 'p5'], skippedItems: [], verbose: false }, ); }).toThrow( new OptionValidationError( @@ -164,12 +166,12 @@ describe('validateFilterOption', () => { validateFilterOption( 'skipPlugins', { plugins: allPlugins }, - { itemsToFilter: ['plugin1'], verbose: false }, + { itemsToFilter: ['plugin1'], skippedItems: [], verbose: false }, ); validateFilterOption( 'onlyPlugins', { plugins: allPlugins }, - { itemsToFilter: ['plugin3'], verbose: false }, + { itemsToFilter: ['plugin3'], skippedItems: [], verbose: false }, ); }).toThrow( new OptionValidationError( @@ -197,7 +199,7 @@ describe('validateFilterOption', () => { }, ] as CategoryConfig[], }, - { itemsToFilter: ['c2', 'c3'], verbose: false }, + { itemsToFilter: ['c2', 'c3'], skippedItems: [], verbose: false }, ); }).toThrow( new OptionValidationError( @@ -205,6 +207,25 @@ describe('validateFilterOption', () => { ), ); }); + + it('should log skipped items if verbose mode is enabled', () => { + const plugins = [ + { slug: 'p1', audits: [{ slug: 'a1-p1' }] }, + ] as PluginConfig[]; + const categories = [ + { slug: 'c1', refs: [{ plugin: 'p1', slug: 'a1-p1' }] }, + ] as CategoryConfig[]; + + validateFilterOption( + 'skipPlugins', + { plugins, categories }, + { itemsToFilter: ['p1'], skippedItems: ['p1'], verbose: true }, + ); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toContain( + 'The --skipPlugins argument references a skipped plugin: p1.', + ); + }); }); describe('createValidationMessage', () => { @@ -252,7 +273,7 @@ describe('createValidationMessage', () => { createValidationMessage( option as FilterOptionType, invalidPlugins, - validPlugins.map(slug => ({ slug })), + new Set(validPlugins), ), ).toBe(expected); }, @@ -321,18 +342,80 @@ describe('validateFinalState', () => { it('should perform validation without throwing an error when categories are missing', () => { const filteredItems = { - plugins: [{ slug: 'p1', audits: [{ slug: 'a1-p1' }] }] as PluginConfig[], + plugins: [ + { + slug: 'p1', + audits: [{ slug: 'a1-p1' }], + groups: [{ slug: 'g1-p1', refs: [{ slug: 'a1-p1', weight: 1 }] }], + }, + ] as PluginConfig[], }; const originalItems = { plugins: [ - { slug: 'p1', audits: [{ slug: 'a1-p1' }] }, - { slug: 'p2', audits: [{ slug: 'a1-p2' }] }, + { + slug: 'p1', + audits: [{ slug: 'a1-p1' }], + groups: [{ slug: 'g1-p1', refs: [{ slug: 'a1-p1', weight: 1 }] }], + }, + { + slug: 'p2', + audits: [{ slug: 'a1-p2' }], + groups: [{ slug: 'g1-p2', refs: [{ slug: 'a1-p2', weight: 1 }] }], + }, ] as PluginConfig[], }; expect(() => { validateFinalState(filteredItems, originalItems); }).not.toThrow(); }); + + it('should throw OptionValidationError when all groups in plugins have zero weight', () => { + const items = { + plugins: [ + { + slug: 'p1', + audits: [ + { slug: 'a1', isSkipped: false }, + { slug: 'a2', isSkipped: false }, + ], + groups: [ + { slug: 'g1', refs: [{ slug: 'a1', weight: 0 }], isSkipped: false }, + { slug: 'g2', refs: [{ slug: 'a2', weight: 0 }], isSkipped: false }, + ], + }, + ] as PluginConfig[], + }; + expect(() => { + validateFinalState(items, items); + }).toThrow( + new OptionValidationError( + 'Some groups in the filtered plugins have only zero-weight references. Please adjust your filters or weights.', + ), + ); + }); + + it('should throw an error when at least one group has all zero-weigh refs', () => { + const items = { + plugins: [ + { + slug: 'p1', + audits: [ + { slug: 'a1', isSkipped: false }, + { slug: 'a2', isSkipped: false }, + ], + groups: [ + { slug: 'g1', refs: [{ slug: 'a1', weight: 1 }], isSkipped: false }, + { slug: 'g2', refs: [{ slug: 'a2', weight: 0 }], isSkipped: false }, + ], + }, + ] as PluginConfig[], + }; + expect(() => { + validateFinalState(items, items); + }).toThrow( + 'Some groups in the filtered plugins have only zero-weight references. Please adjust your filters or weights.', + ); + }); }); describe('getItemType', () => { @@ -349,3 +432,101 @@ describe('getItemType', () => { }, ); }); + +describe('validateSkippedCategories', () => { + const categories = [ + { + slug: 'c1', + refs: [{ type: 'group', plugin: 'p1', slug: 'g1', weight: 0 }], + }, + { + slug: 'c2', + refs: [{ type: 'audit', plugin: 'p2', slug: 'a1', weight: 1 }], + }, + ] as NonNullable; + + it('should log info when categories are removed', () => { + const loggerSpy = vi.spyOn(ui().logger, 'info'); + validateSkippedCategories( + categories, + [ + { + slug: 'c2', + refs: [{ type: 'audit', plugin: 'p2', slug: 'a1', weight: 1 }], + }, + ] as NonNullable, + true, + ); + expect(loggerSpy).toHaveBeenCalledWith( + 'Category c1 was removed because all its refs were skipped. Affected refs: g1 (group)', + ); + }); + + it('should not log anything when categories are not removed', () => { + const loggerSpy = vi.spyOn(ui().logger, 'info'); + validateSkippedCategories(categories, categories, true); + expect(loggerSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error when no categories remain after filtering', () => { + expect(() => validateSkippedCategories(categories, [], false)).toThrow( + new OptionValidationError( + 'No categories remain after filtering. Removed categories: c1, c2', + ), + ); + }); +}); + +describe('pluginHasZeroWeightRefs', () => { + it('should return true if any group has all refs with zero weight', () => { + expect( + pluginHasZeroWeightRefs({ + groups: [ + { + slug: 'g1', + refs: [ + { slug: 'a1', weight: 0 }, + { slug: 'a2', weight: 0 }, + ], + }, + { + slug: 'g2', + refs: [ + { slug: 'a3', weight: 1 }, + { slug: 'a4', weight: 0 }, + ], + }, + ], + } as PluginConfig), + ).toBe(true); + }); + + it('should return false if any ref has non-zero weight', () => { + expect( + pluginHasZeroWeightRefs({ + groups: [ + { + slug: 'g1', + refs: [ + { slug: 'a1', weight: 1 }, + { slug: 'a2', weight: 0 }, + ], + }, + { + slug: 'g2', + refs: [ + { slug: 'a3', weight: 1 }, + { slug: 'a4', weight: 0 }, + ], + }, + ], + } as PluginConfig), + ).toBe(false); + }); + + it('should return false if there are no groups', () => { + expect(pluginHasZeroWeightRefs({ groups: undefined } as PluginConfig)).toBe( + false, + ); + }); +}); diff --git a/packages/models/docs/models-reference.md b/packages/models/docs/models-reference.md index 7bbe15a54..c63bdfd93 100644 --- a/packages/models/docs/models-reference.md +++ b/packages/models/docs/models-reference.md @@ -24,7 +24,7 @@ _Object containing the following properties:_ | `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | | **`scores`** (\*) | Score comparison | _Object with properties:_
  • `before`: `number` (_≥0, ≤1_) - Value between 0 and 1 (source commit)
  • `after`: `number` (_≥0, ≤1_) - Value between 0 and 1 (target commit)
  • `diff`: `number` (_≥-1, ≤1_) - Score change (`scores.after - scores.before`)
| | **`plugin`** (\*) | Plugin which defines it | _Object with properties:_
  • `slug`: `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) - Unique plugin slug within core config
  • `title`: `string` (_max length: 256_) - Descriptive name
  • `docsUrl`: `string` (_url_) (_optional_) _or_ `''` - Plugin documentation site
| -| **`values`** (\*) | Audit `value` comparison | _Object with properties:_
  • `before`: `number` (_≥0_) - Raw numeric value (source commit)
  • `after`: `number` (_≥0_) - Raw numeric value (target commit)
  • `diff`: `number` (_int_) - Value change (`values.after - values.before`)
| +| **`values`** (\*) | Audit `value` comparison | _Object with properties:_
  • `before`: `number` (_≥0_) - Raw numeric value (source commit)
  • `after`: `number` (_≥0_) - Raw numeric value (target commit)
  • `diff`: `number` - Value change (`values.after - values.before`)
| | **`displayValues`** (\*) | Audit `displayValue` comparison | _Object with properties:_
  • `before`: `string` - Formatted value (e.g. '0.9 s', '2.1 MB') (source commit)
  • `after`: `string` - Formatted value (e.g. '0.9 s', '2.1 MB') (target commit)
| _(\*) Required._ @@ -59,6 +59,7 @@ _Object containing the following properties:_ | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | | `docsUrl` | Link to documentation (rationale) | `string` (_url_) (_optional_) _or_ `''` | +| `isSkipped` | Indicates whether the audit is skipped | `boolean` | | `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` | | **`value`** (\*) | Raw numeric value | `number` (_≥0_) | | **`score`** (\*) | Value between 0 and 1 | `number` (_≥0, ≤1_) | @@ -86,12 +87,13 @@ _(\*) Required._ _Object containing the following properties:_ -| Property | Description | Type | -| :--------------- | :-------------------------------- | :---------------------------------------------------------------- | -| **`slug`** (\*) | ID (unique within plugin) | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) | -| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `description` | Description (markdown) | `string` (_max length: 65536_) | -| `docsUrl` | Link to documentation (rationale) | `string` (_url_) (_optional_) _or_ `''` | +| Property | Description | Type | +| :--------------- | :------------------------------------- | :---------------------------------------------------------------- | +| **`slug`** (\*) | ID (unique within plugin) | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) | +| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | +| `description` | Description (markdown) | `string` (_max length: 65536_) | +| `docsUrl` | Link to documentation (rationale) | `string` (_url_) (_optional_) _or_ `''` | +| `isSkipped` | Indicates whether the audit is skipped | `boolean` | _(\*) Required._ @@ -106,6 +108,7 @@ _Object containing the following properties:_ | **`title`** (\*) | Category Title | `string` (_max length: 256_) | | `description` | Category description | `string` (_max length: 65536_) | | `docsUrl` | Category docs URL | `string` (_url_) (_optional_) _or_ `''` | +| `isSkipped` | | `boolean` | | `isBinary` | Is this a binary category (i.e. only a perfect score considered a "pass")? | `boolean` | _(\*) Required._ @@ -236,6 +239,7 @@ _Object containing the following properties:_ | **`title`** (\*) | Descriptive name for the group | `string` (_max length: 256_) | | `description` | Description of the group (markdown) | `string` (_max length: 65536_) | | `docsUrl` | Group documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `isSkipped` | Indicates whether the group is skipped | `boolean` | _(\*) Required._ @@ -245,11 +249,11 @@ Issue information _Object containing the following properties:_ -| Property | Description | Type | -| :------------------ | :------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`message`** (\*) | Descriptive error message | `string` (_max length: 1024_) | -| **`severity`** (\*) | Severity level | [IssueSeverity](#issueseverity) | -| `source` | Source file location | _Object with properties:_
  • `file`: `string` (_min length: 1_) - Relative path to source file in Git repo
  • `position`: _Object with properties:_
    • `startLine`: `number` (_int, >0_) - Start line
    • `startColumn`: `number` (_int, >0_) - Start column
    • `endLine`: `number` (_int, >0_) - End line
    • `endColumn`: `number` (_int, >0_) - End column
    - Location in file
| +| Property | Description | Type | +| :------------------ | :------------------------ | :---------------------------------------- | +| **`message`** (\*) | Descriptive error message | `string` (_max length: 1024_) | +| **`severity`** (\*) | Severity level | [IssueSeverity](#issueseverity) | +| `source` | Source file location | [SourceFileLocation](#sourcefilelocation) | _(\*) Required._ @@ -270,7 +274,7 @@ Icon from VSCode Material Icons extension _Enum string, one of the following possible values:_
-Expand for full list of 839 values +Expand for full list of 858 values - `'git'` - `'yaml'` @@ -481,6 +485,8 @@ _Enum string, one of the following possible values:_ - `'vercel'` - `'vercel_light'` - `'verdaccio'` +- `'payload'` +- `'payload_light'` - `'next'` - `'next_light'` - `'remix'` @@ -574,6 +580,7 @@ _Enum string, one of the following possible values:_ - `'hcl_light'` - `'helm'` - `'san'` +- `'quokka'` - `'wallaby'` - `'stencil'` - `'red'` @@ -634,7 +641,7 @@ _Enum string, one of the following possible values:_ - `'meson'` - `'commitlint'` - `'buck'` -- `'nrwl'` +- `'nx'` - `'opam'` - `'dune'` - `'imba'` @@ -703,13 +710,18 @@ _Enum string, one of the following possible values:_ - `'pnpm_light'` - `'gridsome'` - `'steadybit'` +- `'capnp'` - `'caddy'` +- `'openapi'` +- `'openapi_light'` +- `'swagger'` - `'bun'` - `'bun_light'` - `'antlr'` - `'pinejs'` - `'nano-staged'` - `'nano-staged_light'` +- `'knip'` - `'taskfile'` - `'craco'` - `'gamemaker'` @@ -723,6 +735,7 @@ _Enum string, one of the following possible values:_ - `'unocss'` - `'ifanr-cloud'` - `'mermaid'` +- `'syncpack'` - `'werf'` - `'roblox'` - `'panda'` @@ -749,6 +762,12 @@ _Enum string, one of the following possible values:_ - `'folder-css-open'` - `'folder-sass'` - `'folder-sass-open'` +- `'folder-television'` +- `'folder-television-open'` +- `'folder-desktop'` +- `'folder-desktop-open'` +- `'folder-console'` +- `'folder-console-open'` - `'folder-images'` - `'folder-images-open'` - `'folder-scripts'` @@ -1107,6 +1126,10 @@ _Enum string, one of the following possible values:_ - `'folder-lottie-open'` - `'folder-taskfile'` - `'folder-taskfile-open'` +- `'folder-cloudflare'` +- `'folder-cloudflare-open'` +- `'folder-seeders'` +- `'folder-seeders-open'` - `'folder'` - `'folder-open'` - `'folder-root'` @@ -1149,6 +1172,7 @@ _Object containing the following properties:_ | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | | `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `isSkipped` | | `boolean` | | **`slug`** (\*) | Unique plugin slug within core config | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) | | **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) | | **`runner`** (\*) | | [RunnerConfig](#runnerconfig) _or_ [RunnerFunction](#runnerfunction) | @@ -1168,6 +1192,7 @@ _Object containing the following properties:_ | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | | `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `isSkipped` | | `boolean` | | **`slug`** (\*) | Unique plugin slug within core config | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) | | **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) | @@ -1184,6 +1209,7 @@ _Object containing the following properties:_ | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | | `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `isSkipped` | | `boolean` | | **`slug`** (\*) | Unique plugin slug within core config | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) | | **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) | | **`date`** (\*) | Start date and time of plugin run | `string` | @@ -1203,8 +1229,8 @@ _Object containing the following properties:_ | **`version`** (\*) | NPM version of the CLI | `string` | | **`date`** (\*) | Start date and time of the collect run | `string` | | **`duration`** (\*) | Duration of the collect run in ms | `number` | -| **`categories`** (\*) | | _Array of [CategoryConfig](#categoryconfig) items_ | | **`plugins`** (\*) | | _Array of at least 1 [PluginReport](#pluginreport) items_ | +| `categories` | | _Array of [CategoryConfig](#categoryconfig) items_ | | **`commit`** (\*) | Git commit for which report was collected | _Object with properties:_
  • `hash`: `string` (_regex: `/^[\da-f]{40}$/`_) - Commit SHA (full)
  • `message`: `string` - Commit message
  • `date`: `Date` (_nullable_) - Date and time when commit was authored
  • `author`: `string` - Commit author name
(_nullable_) | _(\*) Required._ @@ -1255,6 +1281,19 @@ _Returns:_ - [AuditOutputs](#auditoutputs) _or_ _Promise of_ [AuditOutputs](#auditoutputs) +## SourceFileLocation + +Source file location + +_Object containing the following properties:_ + +| Property | Description | Type | +| :-------------- | :--------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`file`** (\*) | Relative path to source file in Git repo | `string` (_min length: 1_) | +| `position` | Location in file | _Object with properties:_
  • `startLine`: `number` (_int, >0_) - Start line
  • `startColumn`: `number` (_int, >0_) - Start column
  • `endLine`: `number` (_int, >0_) - End line
  • `endColumn`: `number` (_int, >0_) - End column
| + +_(\*) Required._ + ## TableAlignment Cell alignment diff --git a/packages/models/src/lib/audit.ts b/packages/models/src/lib/audit.ts index 3b45289a2..e28b9ef61 100644 --- a/packages/models/src/lib/audit.ts +++ b/packages/models/src/lib/audit.ts @@ -12,6 +12,7 @@ export const auditSchema = z descriptionDescription: 'Description (markdown)', docsUrlDescription: 'Link to documentation (rationale)', description: 'List of scorable metrics for the given plugin', + isSkippedDescription: 'Indicates whether the audit is skipped', }), ); diff --git a/packages/models/src/lib/core-config.unit.test.ts b/packages/models/src/lib/core-config.unit.test.ts index 65d2105d4..edfb6fa73 100644 --- a/packages/models/src/lib/core-config.unit.test.ts +++ b/packages/models/src/lib/core-config.unit.test.ts @@ -136,4 +136,60 @@ describe('coreConfigSchema', () => { 'category references need to point to an audit or group: eslint#eslint-errors (group)', ); }); + + it('should throw for a category with a zero-weight audit', () => { + const config = { + categories: [ + { + slug: 'performance', + title: 'Performance', + refs: [ + { + slug: 'performance', + weight: 1, + type: 'group', + plugin: 'lighthouse', + }, + ], + }, + { + slug: 'best-practices', + title: 'Best practices', + refs: [ + { + slug: 'best-practices', + weight: 1, + type: 'group', + plugin: 'lighthouse', + }, + ], + }, + ], + plugins: [ + { + slug: 'lighthouse', + title: 'Lighthouse', + icon: 'lighthouse', + runner: { command: 'npm run lint', outputFile: 'output.json' }, + audits: [ + { + slug: 'csp-xss', + title: 'Ensure CSP is effective against XSS attacks', + }, + ], + groups: [ + { + slug: 'best-practices', + title: 'Best practices', + refs: [{ slug: 'csp-xss', weight: 0 }], + }, + ], + }, + ], + } satisfies CoreConfig; + + expect(() => coreConfigSchema.parse(config)).toThrow( + 'In a category, there has to be at least one ref with weight > 0. Affected refs: csp-xss', + ); + }); }); diff --git a/packages/models/src/lib/group.ts b/packages/models/src/lib/group.ts index 31e795cd9..faaa55b4e 100644 --- a/packages/models/src/lib/group.ts +++ b/packages/models/src/lib/group.ts @@ -22,6 +22,7 @@ export const groupMetaSchema = metaSchema({ descriptionDescription: 'Description of the group (markdown)', docsUrlDescription: 'Group documentation site', description: 'Group metadata', + isSkippedDescription: 'Indicates whether the group is skipped', }); export type GroupMeta = z.infer; diff --git a/packages/models/src/lib/implementation/schemas.ts b/packages/models/src/lib/implementation/schemas.ts index f68436687..135a84c13 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -69,6 +69,9 @@ export const scoreSchema = z .min(0) .max(1); +/** Schema for a property indicating whether an entity is filtered out */ +export const isSkippedSchema = z.boolean().optional(); + /** * Used for categories, plugins and audits * @param options @@ -78,12 +81,14 @@ export function metaSchema(options?: { descriptionDescription?: string; docsUrlDescription?: string; description?: string; + isSkippedDescription?: string; }) { const { descriptionDescription, titleDescription, docsUrlDescription, description, + isSkippedDescription, } = options ?? {}; return z.object( { @@ -96,6 +101,9 @@ export function metaSchema(options?: { docsUrl: docsUrlDescription ? docsUrlSchema.describe(docsUrlDescription) : docsUrlSchema, + isSkipped: isSkippedDescription + ? isSkippedSchema.describe(isSkippedDescription) + : isSkippedSchema, }, { description }, ); diff --git a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts index 7683f4586..ba452f94f 100644 --- a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts +++ b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts @@ -8,7 +8,7 @@ import { } from './runner/constants.js'; import { createRunnerFunction } from './runner/runner.js'; import type { LighthouseOptions } from './types.js'; -import { filterAuditsAndGroupsByOnlyOptions } from './utils.js'; +import { markSkippedAuditsAndGroups } from './utils.js'; export function lighthousePlugin( url: string, @@ -17,7 +17,7 @@ export function lighthousePlugin( const { skipAudits, onlyAudits, onlyCategories, ...unparsedFlags } = normalizeFlags(flags ?? {}); - const { audits, groups } = filterAuditsAndGroupsByOnlyOptions( + const { audits, groups } = markSkippedAuditsAndGroups( LIGHTHOUSE_NAVIGATION_AUDITS, LIGHTHOUSE_GROUPS, { skipAudits, onlyAudits, onlyCategories }, diff --git a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts index 18034a36d..3ac6a8419 100644 --- a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts @@ -1,6 +1,7 @@ import { expect } from 'vitest'; import { pluginConfigSchema } from '@code-pushup/models'; import { lighthousePlugin } from './lighthouse-plugin.js'; +import type { LighthouseOptions } from './types.js'; describe('lighthousePlugin-config-object', () => { it('should create valid plugin config', () => { @@ -17,47 +18,73 @@ describe('lighthousePlugin-config-object', () => { ]); }); - it('should filter audits by onlyAudits string "first-contentful-paint"', () => { - const pluginConfig = lighthousePlugin('https://code-pushup-portal.com', { - onlyAudits: ['first-contentful-paint'], - }); - - expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + it.each([ + [ + { onlyAudits: ['first-contentful-paint'] }, + 'first-contentful-paint', + false, + ], + [ + { onlyAudits: ['first-contentful-paint'] }, + 'largest-contentful-paint', + true, + ], + [ + { skipAudits: ['first-contentful-paint'] }, + 'first-contentful-paint', + true, + ], + [ + { skipAudits: ['first-contentful-paint'] }, + 'largest-contentful-paint', + false, + ], + ])( + 'should apply option %o and set the "%s" audit skipped status to %s', + (option, audit, isSkipped) => { + const pluginConfig = lighthousePlugin( + 'https://code-pushup-portal.com', + option as LighthouseOptions, + ); + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + expect(pluginConfig.audits.find(({ slug }) => audit === slug)).toEqual( + expect.objectContaining({ isSkipped }), + ); + }, + ); - expect(pluginConfig.audits[0]).toEqual( - expect.objectContaining({ - slug: 'first-contentful-paint', - }), - ); - }); + it.each([ + [{ onlyGroups: ['performance'] }, 'performance', false], + [{ onlyGroups: ['performance'] }, 'accessibility', true], + ])( + 'should apply option %o and set the "%s" group skipped status to %s', + (option, group, isSkipped) => { + const pluginConfig = lighthousePlugin( + 'https://code-pushup-portal.com', + option as LighthouseOptions, + ); + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + expect(pluginConfig.groups?.find(({ slug }) => group === slug)).toEqual( + expect.objectContaining({ isSkipped }), + ); + }, + ); - it('should filter groups by onlyAudits string "first-contentful-paint"', () => { + it('should mark groups referencing audits in onlyAudits as not skipped', () => { const pluginConfig = lighthousePlugin('https://code-pushup-portal.com', { onlyAudits: ['first-contentful-paint'], }); expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); - expect(pluginConfig.groups).toHaveLength(1); - - const refs = pluginConfig.groups?.[0]?.refs; - expect(refs).toHaveLength(1); - expect(refs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - slug: 'first-contentful-paint', - }), - ]), + const group = pluginConfig.groups?.find(({ refs }) => + refs.some(ref => ref.slug === 'first-contentful-paint'), ); - }); - - it('should throw when filtering groups by zero-weight onlyAudits', () => { - const pluginConfig = lighthousePlugin('https://code-pushup-portal.com', { - onlyAudits: ['csp-xss'], - }); - expect(() => pluginConfigSchema.parse(pluginConfig)).toThrow( - 'In a category, there has to be at least one ref with weight > 0. Affected refs: csp-xss', + expect(group).toEqual( + expect.objectContaining({ + isSkipped: false, + }), ); }); }); diff --git a/packages/plugin-lighthouse/src/lib/utils.ts b/packages/plugin-lighthouse/src/lib/utils.ts index e889b617d..6c1469548 100644 --- a/packages/plugin-lighthouse/src/lib/utils.ts +++ b/packages/plugin-lighthouse/src/lib/utils.ts @@ -1,5 +1,5 @@ import type { Audit, CategoryRef, Group } from '@code-pushup/models'; -import { filterItemRefsBy, toArray } from '@code-pushup/utils'; +import { toArray } from '@code-pushup/utils'; import { LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; import type { LighthouseCliFlags } from './runner/types.js'; @@ -70,7 +70,8 @@ export type FilterOptions = Partial< Pick >; -export function filterAuditsAndGroupsByOnlyOptions( +// eslint-disable-next-line max-lines-per-function +export function markSkippedAuditsAndGroups( audits: Audit[], groups: Group[], options?: FilterOptions, @@ -84,45 +85,57 @@ export function filterAuditsAndGroupsByOnlyOptions( onlyCategories = [], } = options ?? {}; - // category wins over audits + if ( + onlyCategories.length === 0 && + onlyAudits.length === 0 && + skipAudits.length === 0 + ) { + return { audits, groups }; + } + if (onlyCategories.length > 0) { validateOnlyCategories(groups, onlyCategories); + } - const categorySlugs = new Set(onlyCategories); - const filteredGroups: Group[] = groups.filter(({ slug }) => - categorySlugs.has(slug), - ); - const auditSlugsFromRemainingGroups = new Set( - filteredGroups.flatMap(({ refs }) => refs.map(({ slug }) => slug)), - ); - return { - audits: audits.filter(({ slug }) => - auditSlugsFromRemainingGroups.has(slug), - ), - groups: filteredGroups, - }; - } else if (onlyAudits.length > 0 || skipAudits.length > 0) { + if (onlyAudits.length > 0 || skipAudits.length > 0) { validateAudits(audits, onlyAudits); validateAudits(audits, skipAudits); - const onlyAuditSlugs = new Set(onlyAudits); - const skipAuditSlugs = new Set(skipAudits); - const filterAudits = ({ slug }: Pick) => - !( - // audit is NOT in given onlyAuditSlugs - ( - (onlyAudits.length > 0 && !onlyAuditSlugs.has(slug)) || - // audit IS in given skipAuditSlugs - (skipAudits.length > 0 && skipAuditSlugs.has(slug)) - ) - ); - return { - audits: audits.filter(filterAudits), - groups: filterItemRefsBy(groups, filterAudits), - }; } - // return unchanged + + const onlyGroupSlugs = new Set(onlyCategories); + const onlyAuditSlugs = new Set(onlyAudits); + const skipAuditSlugs = new Set(skipAudits); + + const markedGroups: Group[] = groups.map(group => ({ + ...group, + isSkipped: onlyCategories.length > 0 && !onlyGroupSlugs.has(group.slug), + })); + + const validGroupAuditSlugs = new Set( + markedGroups + .filter(group => !group.isSkipped) + .flatMap(group => group.refs.map(ref => ref.slug)), + ); + + const markedAudits = audits.map(audit => ({ + ...audit, + isSkipped: + (onlyAudits.length > 0 && !onlyAuditSlugs.has(audit.slug)) || + (skipAudits.length > 0 && skipAuditSlugs.has(audit.slug)) || + (validGroupAuditSlugs.size > 0 && !validGroupAuditSlugs.has(audit.slug)), + })); + + const fullyMarkedGroups = markedGroups.map(group => ({ + ...group, + isSkipped: + group.isSkipped || + group.refs.every(ref => + markedAudits.some(audit => audit.slug === ref.slug && audit.isSkipped), + ), + })); + return { - audits, - groups, + audits: markedAudits, + groups: fullyMarkedGroups, }; } diff --git a/packages/plugin-lighthouse/src/lib/utils.unit.test.ts b/packages/plugin-lighthouse/src/lib/utils.unit.test.ts index 61aa68a84..c9d816aed 100644 --- a/packages/plugin-lighthouse/src/lib/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/utils.unit.test.ts @@ -9,9 +9,9 @@ import { import { AuditsNotImplementedError, CategoriesNotImplementedError, - filterAuditsAndGroupsByOnlyOptions, lighthouseAuditRef, lighthouseGroupRef, + markSkippedAuditsAndGroups, validateAudits, validateOnlyCategories, } from './utils.js'; @@ -111,7 +111,7 @@ describe('validateOnlyCategories', () => { }); }); -describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => { +describe('markSkippedAuditsAndGroups to be used in plugin config', () => { type PartialGroup = Partial< Omit & { refs: Partial[] } >; @@ -149,19 +149,19 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => refs: [{ slug: 'speed-index' }], }, ] as Group[]; - const { audits: filteredAudits, groups: filteredGroups } = - filterAuditsAndGroupsByOnlyOptions(audits, groups, {}); + const { audits: markedAudits, groups: markedGroups } = + markSkippedAuditsAndGroups(audits, groups, {}); - expect(filteredAudits).toStrictEqual(audits); - expect(filteredGroups).toStrictEqual(groups); + expect(markedAudits).toStrictEqual(audits); + expect(markedGroups).toStrictEqual(groups); - const pluginConfig = basePluginConfig(filteredAudits, filteredGroups); + const pluginConfig = basePluginConfig(markedAudits, markedGroups); expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); }); - it('should filter audits if skipAudits is set', () => { - const { audits: filteredAudits, groups: filteredGroups } = - filterAuditsAndGroupsByOnlyOptions( + it('should mark audits as skipped when skipAudits is set', () => { + const { audits: markedAudits, groups: markedGroups } = + markSkippedAuditsAndGroups( [ { slug: 'speed-index' }, { slug: 'first-contentful-paint' }, @@ -175,21 +175,25 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => { skipAudits: ['speed-index'] }, ); - expect(filteredAudits).toStrictEqual([{ slug: 'first-contentful-paint' }]); - expect(filteredGroups).toStrictEqual([ + expect(markedAudits).toStrictEqual([ + { slug: 'speed-index', isSkipped: true }, + { slug: 'first-contentful-paint', isSkipped: false }, + ]); + expect(markedGroups).toStrictEqual([ { slug: 'performance', - refs: [{ slug: 'first-contentful-paint' }], + isSkipped: false, + refs: [{ slug: 'speed-index' }, { slug: 'first-contentful-paint' }], }, ]); - const pluginConfig = basePluginConfig(filteredAudits, filteredGroups); + const pluginConfig = basePluginConfig(markedAudits, markedGroups); expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); }); it('should throw if skipAudits is set with a missing audit slug', () => { expect(() => - filterAuditsAndGroupsByOnlyOptions( + markSkippedAuditsAndGroups( [ { slug: 'speed-index' }, { slug: 'first-contentful-paint' }, @@ -208,9 +212,9 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => ).toThrow(new AuditsNotImplementedError(['missing-audit'])); }); - it('should filter audits if onlyAudits is set', () => { - const { audits: filteredAudits, groups: filteredGroups } = - filterAuditsAndGroupsByOnlyOptions( + it('should mark audits as not skipped when onlyAudits is set', () => { + const { audits: markedAudits, groups: markedGroups } = + markSkippedAuditsAndGroups( [ { slug: 'speed-index' }, { slug: 'first-contentful-paint' }, @@ -224,20 +228,24 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => { onlyAudits: ['speed-index'] }, ); - expect(filteredAudits).toStrictEqual([{ slug: 'speed-index' }]); - expect(filteredGroups).toStrictEqual([ + expect(markedAudits).toStrictEqual([ + { slug: 'speed-index', isSkipped: false }, + { slug: 'first-contentful-paint', isSkipped: true }, + ]); + expect(markedGroups).toStrictEqual([ { slug: 'performance', + isSkipped: false, refs: [{ slug: 'speed-index' }], }, ]); - const pluginConfig = basePluginConfig(filteredAudits, filteredGroups); + const pluginConfig = basePluginConfig(markedAudits, markedGroups); expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); }); it('should throw if onlyAudits is set with a missing audit slug', () => { expect(() => - filterAuditsAndGroupsByOnlyOptions( + markSkippedAuditsAndGroups( [ { slug: 'speed-index' }, { slug: 'first-contentful-paint' }, @@ -253,9 +261,9 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => ).toThrow(new AuditsNotImplementedError(['missing-audit'])); }); - it('should filter group if onlyGroups is set', () => { - const { audits: filteredAudits, groups: filteredGroups } = - filterAuditsAndGroupsByOnlyOptions( + it('should mark skipped audits and groups when onlyGroups is set', () => { + const { audits: markedAudits, groups: markedGroups } = + markSkippedAuditsAndGroups( [ { slug: 'speed-index' }, { slug: 'first-contentful-paint' }, @@ -274,21 +282,31 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => { onlyCategories: ['coverage'] }, ); - expect(filteredAudits).toStrictEqual([{ slug: 'function-coverage' }]); - expect(filteredGroups).toStrictEqual([ + expect(markedAudits).toStrictEqual([ + { slug: 'speed-index', isSkipped: true }, + { slug: 'first-contentful-paint', isSkipped: true }, + { slug: 'function-coverage', isSkipped: false }, + ]); + expect(markedGroups).toStrictEqual([ + { + slug: 'performance', + isSkipped: true, + refs: [{ slug: 'speed-index' }], + }, { slug: 'coverage', + isSkipped: false, refs: [{ slug: 'function-coverage' }], }, ]); - const pluginConfig = basePluginConfig(filteredAudits, filteredGroups); + const pluginConfig = basePluginConfig(markedAudits, markedGroups); expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); }); - it('should ignore onlyAudits and only filter groups if onlyGroups and onlyAudits is set', () => { - const { audits: filteredAudits, groups: filteredGroups } = - filterAuditsAndGroupsByOnlyOptions( + it('should handle mixed onlyGroups and onlyAudits filters', () => { + const { audits: markedAudits, groups: markedGroups } = + markSkippedAuditsAndGroups( [ { slug: 'speed-index' }, { slug: 'first-contentful-paint' }, @@ -310,21 +328,63 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => }, ); - expect(filteredAudits).toStrictEqual([{ slug: 'function-coverage' }]); - expect(filteredGroups).toStrictEqual([ + expect(markedAudits).toStrictEqual([ + { slug: 'speed-index', isSkipped: true }, + { slug: 'first-contentful-paint', isSkipped: true }, + { slug: 'function-coverage', isSkipped: true }, + ]); + expect(markedGroups).toStrictEqual([ + { + slug: 'performance', + isSkipped: true, + refs: [{ slug: 'speed-index' }], + }, { slug: 'coverage', + isSkipped: true, refs: [{ slug: 'function-coverage' }], }, ]); - const pluginConfig = basePluginConfig(filteredAudits, filteredGroups); + const pluginConfig = basePluginConfig(markedAudits, markedGroups); + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + }); + + it('should mark a group as skipped if all of its audits are skipped', () => { + const { audits: markedAudits, groups: markedGroups } = + markSkippedAuditsAndGroups( + [ + { slug: 'speed-index' }, + { slug: 'first-contentful-paint' }, + ] as Audit[], + [ + { + slug: 'performance', + refs: [{ slug: 'speed-index' }, { slug: 'first-contentful-paint' }], + }, + ] as Group[], + { skipAudits: ['speed-index', 'first-contentful-paint'] }, + ); + + expect(markedAudits).toStrictEqual([ + { slug: 'speed-index', isSkipped: true }, + { slug: 'first-contentful-paint', isSkipped: true }, + ]); + expect(markedGroups).toStrictEqual([ + { + slug: 'performance', + isSkipped: true, + refs: [{ slug: 'speed-index' }, { slug: 'first-contentful-paint' }], + }, + ]); + + const pluginConfig = basePluginConfig(markedAudits, markedGroups); expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); }); it('should throw if onlyAudits is set with a audit slug that is not implemented', () => { expect(() => - filterAuditsAndGroupsByOnlyOptions( + markSkippedAuditsAndGroups( [{ slug: 'speed-index' }] as Audit[], [ { @@ -341,7 +401,7 @@ describe('filterAuditsAndGroupsByOnlyOptions to be used in plugin config', () => it('should throw if onlyGroups is set with a group slug that is not implemented', () => { expect(() => - filterAuditsAndGroupsByOnlyOptions( + markSkippedAuditsAndGroups( [{ slug: 'speed-index' }] as Audit[], [ {