From 9b8c960747e19620cdefb751a6a5185ffc938668 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Fri, 3 Jan 2025 19:24:20 -0500 Subject: [PATCH 1/5] feat: add isSkipped to audits and groups for filtering --- packages/models/src/lib/audit.ts | 1 + packages/models/src/lib/group.ts | 1 + .../models/src/lib/implementation/schemas.ts | 8 ++ .../src/lib/lighthouse-plugin.ts | 4 +- .../src/lib/lighthouse-plugin.unit.test.ts | 35 ++--- packages/plugin-lighthouse/src/lib/utils.ts | 83 ++++++----- .../src/lib/utils.unit.test.ts | 132 +++++++++++++----- 7 files changed, 168 insertions(+), 96 deletions(-) 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/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..4e2669a14 100644 --- a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts @@ -17,47 +17,36 @@ describe('lighthousePlugin-config-object', () => { ]); }); - it('should filter audits by onlyAudits string "first-contentful-paint"', () => { + it('should mark 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.audits[0]).toEqual( + expect( + pluginConfig.audits.find(({ slug }) => slug === 'first-contentful-paint'), + ).toEqual( expect.objectContaining({ - slug: 'first-contentful-paint', + isSkipped: false, }), ); }); - 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[], [ { From 5ebadbbd53894e9b20fd0a0b39f06d90fc6fbb6f Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 6 Jan 2025 19:21:41 -0500 Subject: [PATCH 2/5] test(models): validate error for zero-weight refs in core config --- .../models/src/lib/core-config.unit.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) 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', + ); + }); }); From 10e761b1803a7f808ddd7072fbc29aa584f68751 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 6 Jan 2025 19:18:39 -0500 Subject: [PATCH 3/5] feat(cli): handle skipped audits and groups in filter middleware --- .../lib/implementation/filter.middleware.ts | 69 +++- .../filter.middleware.unit.test.ts | 349 +++++++++++++----- .../validate-filter-options.utils.ts | 98 ++++- ...validate-filter-options.utils.unit.test.ts | 260 ++++++++++++- 4 files changed, 679 insertions(+), 97 deletions(-) diff --git a/packages/cli/src/lib/implementation/filter.middleware.ts b/packages/cli/src/lib/implementation/filter.middleware.ts index 92f380f2b..5c958d964 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.ts @@ -1,18 +1,25 @@ -import type { CoreConfig } from '@code-pushup/models'; +import type { + CategoryConfig, + CoreConfig, + PluginConfig, +} from '@code-pushup/models'; import { filterItemRefsBy } from '@code-pushup/utils'; import type { FilterOptions, Filterables } from './filter.model.js'; import { handleConflictingOptions, + isValidCategoryRef, validateFilterOption, + validateFilteredCategories, validateFinalState, } 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,13 +27,28 @@ export function filterMiddleware( verbose = false, } = originalProcessArgs; + const plugins = processPlugins(rcPlugins); + const categories = filterSkippedCategories(rcCategories, plugins); + + if (rcCategories && categories) { + validateFilteredCategories(rcCategories, categories, { + onlyCategories, + skipCategories, + verbose, + }); + } + if ( skipCategories.length === 0 && onlyCategories.length === 0 && skipPlugins.length === 0 && onlyPlugins.length === 0 ) { - return originalProcessArgs; + return { + ...originalProcessArgs, + ...(categories && { categories }), + plugins, + }; } handleConflictingOptions('categories', onlyCategories, skipCategories); @@ -52,7 +74,7 @@ export function filterMiddleware( validateFinalState( { categories: finalCategories, plugins: filteredPlugins }, - { categories, plugins }, + { categories: rcCategories, plugins: rcPlugins }, ); return { @@ -141,3 +163,40 @@ function filterPluginsFromCategories({ ); return plugins.filter(plugin => validPluginSlugs.has(plugin.slug)); } + +function filterSkippedItems( + items: T[] | undefined, +): Omit[] { + return (items ?? []) + .filter(({ isSkipped }) => isSkipped !== true) + .map(({ isSkipped, ...props }) => props); +} + +export function processPlugins(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..7309ce546 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, + processPlugins, +} 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,104 +91,116 @@ 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'] }, - [{ slug: 'p3' }], - [{ slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }], - ], - [ - { skipCategories: ['c1'], onlyCategories: ['c2'] }, - [{ slug: 'p3' }], - [{ slug: 'c2', refs: [{ plugin: 'p3', slug: 'a1-p3' }] }], - ], - [ - { onlyCategories: ['c1'] }, - [{ slug: 'p1' }, { slug: 'p2' }], - [ - { - slug: 'c1', - refs: [ - { plugin: 'p1', slug: 'a1-p1' }, - { plugin: 'p2', slug: 'a1-p2' }, - ], - }, - ], - ], + [{ 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' }, - { 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); }, ); @@ -193,25 +209,41 @@ describe('filterMiddleware', () => { skipPlugins: ['p1'], onlyCategories: ['c1'], 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([{ slug: 'p2' }]); - expect(categories).toStrictEqual([ - { slug: 'c1', refs: [{ plugin: 'p2', slug: 'a1-p2' }] }, - ]); + const pluginSlugs = plugins.map(({ slug }) => slug); + const categorySlugs = categories?.map(({ slug }) => slug); + + expect(pluginSlugs).toStrictEqual(['p2']); + expect(categorySlugs).toStrictEqual(['c1']); }); it('should trigger verbose logging when skipPlugins or onlyPlugins removes categories', () => { @@ -220,16 +252,22 @@ describe('filterMiddleware', () => { 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 +289,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 +317,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 +336,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('processPlugins', () => { + it('should filter out skipped audits and groups', () => { + expect( + processPlugins([ + { + 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( + processPlugins([ + { + 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/validate-filter-options.utils.ts b/packages/cli/src/lib/implementation/validate-filter-options.utils.ts index c84b4af03..c262e71b2 100644 --- a/packages/cli/src/lib/implementation/validate-filter-options.utils.ts +++ b/packages/cli/src/lib/implementation/validate-filter-options.utils.ts @@ -1,11 +1,19 @@ -import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; +import type { + CategoryConfig, + CategoryRef, + PluginConfig, +} from '@code-pushup/models'; import { capitalize, filterItemRefsBy, pluralize, ui, } from '@code-pushup/utils'; -import type { FilterOptionType, Filterables } from './filter.model.js'; +import type { + FilterOptionType, + FilterOptions, + Filterables, +} from './filter.model.js'; export class OptionValidationError extends Error {} @@ -55,6 +63,68 @@ export function validateFilterOption( } } +export function validateFilteredCategories( + originalCategories: NonNullable, + filteredCategories: NonNullable, + { + onlyCategories, + skipCategories, + verbose, + }: Pick, +): void { + const skippedCategories = originalCategories.filter( + original => !filteredCategories.some(({ slug }) => slug === original.slug), + ); + if (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(', ')}`, + ); + }); + } + const invalidArgs = [ + { option: 'onlyCategories', args: onlyCategories ?? [] }, + { option: 'skipCategories', args: skipCategories ?? [] }, + ].filter(({ args }) => + args.some(arg => skippedCategories.some(({ slug }) => slug === arg)), + ); + if (invalidArgs.length > 0) { + throw new OptionValidationError( + invalidArgs + .map( + ({ option, args }) => + `The --${option} argument references skipped categories: ${args.join(', ')}`, + ) + .join('. '), + ); + } + if (filteredCategories.length === 0) { + throw new OptionValidationError( + `No categories remain after filtering. Removed categories: ${skippedCategories + .map(({ slug }) => slug) + .join(', ')}`, + ); + } +} + +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 validateFinalState( filteredItems: Filterables, originalItems: Filterables, @@ -76,6 +146,30 @@ export function validateFinalState( `Nothing to report. No plugins or categories are available after filtering. Available plugins: ${availablePlugins}. Available categories: ${availableCategories}.`, ); } + if (filteredPlugins.every(pluginHasZeroWeightRefs)) { + throw new OptionValidationError( + `All groups in the filtered plugins have refs with zero weight. Please adjust your filters or weights.`, + ); + } +} + +export function pluginHasZeroWeightRefs( + plugin: Pick, +): boolean { + if (!plugin.groups || plugin.groups.length === 0) { + return false; + } + const weightMap = new Map(); + plugin.groups.forEach(group => { + group.refs.forEach(ref => { + weightMap.set(ref.slug, (weightMap.get(ref.slug) ?? 0) + ref.weight); + }); + }); + const totalWeight = plugin.audits.reduce( + (sum, audit) => sum + (weightMap.get(audit.slug) ?? 0), + 0, + ); + return totalWeight === 0; } function isCategoryOption(option: FilterOptionType): boolean { 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..1ab84de61 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 @@ -1,14 +1,21 @@ import { describe, expect } from 'vitest'; -import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; +import type { + CategoryConfig, + CategoryRef, + 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, + isValidCategoryRef, + pluginHasZeroWeightRefs, validateFilterOption, + validateFilteredCategories, validateFinalState, } from './validate-filter-options.utils.js'; @@ -321,18 +328,78 @@ 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( + 'All groups in the filtered plugins have refs with zero weight. Please adjust your filters or weights.', + ), + ); + }); + + it('should not throw an error when at least one group ref has positive weight', () => { + 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); + }).not.toThrow(); + }); }); describe('getItemType', () => { @@ -349,3 +416,186 @@ describe('getItemType', () => { }, ); }); + +describe('validateFilteredCategories', () => { + 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'); + validateFilteredCategories( + categories, + [ + { + slug: 'c2', + refs: [{ type: 'audit', plugin: 'p2', slug: 'a1', weight: 1 }], + }, + ] as NonNullable, + { verbose: 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'); + validateFilteredCategories(categories, categories, { verbose: true }); + expect(loggerSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error when onlyCategories references skipped categories', () => { + expect(() => + validateFilteredCategories(categories, [], { + onlyCategories: ['c1'], + skipCategories: [], + verbose: false, + }), + ).toThrow( + new OptionValidationError( + 'The --onlyCategories argument references skipped categories: c1', + ), + ); + }); + + it('should throw an error when skipCategories references skipped categories', () => { + expect(() => + validateFilteredCategories(categories, [], { + onlyCategories: [], + skipCategories: ['c1'], + verbose: false, + }), + ).toThrow( + new OptionValidationError( + 'The --skipCategories argument references skipped categories: c1', + ), + ); + }); + + it('should throw an error when no categories remain after filtering', () => { + expect(() => + validateFilteredCategories(categories, [], { + onlyCategories: [], + skipCategories: [], + verbose: false, + }), + ).toThrow( + new OptionValidationError( + 'No categories remain after filtering. Removed categories: c1, c2', + ), + ); + }); +}); + +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('pluginHasZeroWeightRefs', () => { + it('should return true if all refs have zero weight', () => { + expect( + pluginHasZeroWeightRefs({ + groups: [ + { + slug: 'g1', + refs: [ + { slug: 'a1', weight: 0 }, + { slug: 'a2', weight: 0 }, + ], + }, + ], + audits: [{ slug: 'a1' }, { slug: 'a2' }], + } 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 }, + ], + }, + ], + audits: [{ slug: 'a1' }, { slug: 'a2' }], + } as PluginConfig), + ).toBe(false); + }); + + it('should return false if there are no groups', () => { + expect( + pluginHasZeroWeightRefs({ + groups: undefined, + audits: [{ slug: 'a1' }], + } as PluginConfig), + ).toBe(false); + }); +}); From 854f028a8b4af5979a5858b536faddddfa9df527 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 9 Jan 2025 21:01:08 -0500 Subject: [PATCH 4/5] feat(cli): implement filter utilities --- .../lib/implementation/filter.middleware.ts | 101 ++++------ .../filter.middleware.unit.test.ts | 132 ++++++++----- .../implementation/filter.middleware.utils.ts | 67 +++++++ .../filter.middleware.utils.unit.test.ts | 78 ++++++++ .../validate-filter-options.utils.ts | 142 +++++--------- ...validate-filter-options.utils.unit.test.ts | 185 ++++++------------ packages/models/docs/models-reference.md | 69 +++++-- .../src/lib/lighthouse-plugin.unit.test.ts | 64 ++++-- 8 files changed, 483 insertions(+), 355 deletions(-) create mode 100644 packages/cli/src/lib/implementation/filter.middleware.utils.ts create mode 100644 packages/cli/src/lib/implementation/filter.middleware.utils.unit.test.ts diff --git a/packages/cli/src/lib/implementation/filter.middleware.ts b/packages/cli/src/lib/implementation/filter.middleware.ts index 5c958d964..b0c1349b5 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.ts @@ -4,13 +4,19 @@ import type { 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, - isValidCategoryRef, validateFilterOption, - validateFilteredCategories, validateFinalState, + validateSkippedCategories, } from './validate-filter-options.utils.js'; // eslint-disable-next-line max-lines-per-function @@ -27,23 +33,18 @@ export function filterMiddleware( verbose = false, } = originalProcessArgs; - const plugins = processPlugins(rcPlugins); + const plugins = filterSkippedInPlugins(rcPlugins); const categories = filterSkippedCategories(rcCategories, plugins); - if (rcCategories && categories) { - validateFilteredCategories(rcCategories, categories, { - onlyCategories, - skipCategories, - verbose, - }); - } - if ( skipCategories.length === 0 && onlyCategories.length === 0 && skipPlugins.length === 0 && onlyPlugins.length === 0 ) { + if (rcCategories && categories) { + validateSkippedCategories(rcCategories, categories, verbose); + } return { ...originalProcessArgs, ...(categories && { categories }), @@ -54,17 +55,18 @@ export function filterMiddleware( 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 => @@ -84,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, @@ -141,38 +127,19 @@ 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)); -} - -function filterSkippedItems( - items: T[] | undefined, -): Omit[] { - return (items ?? []) - .filter(({ isSkipped }) => isSkipped !== true) - .map(({ isSkipped, ...props }) => props); -} - -export function processPlugins(plugins: PluginConfig[]): PluginConfig[] { +export function filterSkippedInPlugins( + plugins: PluginConfig[], +): PluginConfig[] { return plugins.map((plugin: PluginConfig) => { const filteredAudits = filterSkippedItems(plugin.audits); return { 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 7309ce546..ecc5de17f 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts @@ -3,7 +3,7 @@ import { ui } from '@code-pushup/utils'; import { filterMiddleware, filterSkippedCategories, - processPlugins, + filterSkippedInPlugins, } from './filter.middleware.js'; import { OptionValidationError } from './validate-filter-options.utils.js'; @@ -204,47 +204,91 @@ describe('filterMiddleware', () => { }, ); - it('should filter plugins and categories with mixed filter options', () => { - const { plugins, categories } = filterMiddleware({ - skipPlugins: ['p1'], - onlyCategories: ['c1'], - 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); + it.each([ + [ + { skipPlugins: ['eslint'], onlyCategories: ['performance'] }, + ['lighthouse'], + ['performance'], + ], + [ + { skipCategories: ['performance'], onlyPlugins: ['lighthouse'] }, + ['lighthouse'], + ['best-practices'], + ], + ])( + 'should filter plugins and categories with mixed filter options: %o', + (option, expectedPlugins, expectedCategories) => { + const { plugins, categories } = filterMiddleware({ + ...option, + plugins: [ + { + 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: 'performance', + refs: [ + { + type: 'group', + plugin: 'lighthouse', + slug: 'performance', + weight: 1, + }, + ], + }, + { + slug: 'best-practices', + refs: [ + { + type: 'group', + plugin: 'lighthouse', + slug: 'best-practices', + weight: 1, + }, + ], + }, + { + slug: 'bug-prevention', + refs: [ + { + type: 'group', + plugin: 'eslint', + slug: 'problems', + weight: 1, + }, + ], + }, + ] as CategoryConfig[], + }); + const pluginSlugs = plugins.map(({ slug }) => slug); + const categorySlugs = categories?.map(({ slug }) => slug); - expect(pluginSlugs).toStrictEqual(['p2']); - expect(categorySlugs).toStrictEqual(['c1']); - }); + expect(pluginSlugs).toStrictEqual(expectedPlugins); + expect(categorySlugs).toStrictEqual(expectedCategories); + }, + ); it('should trigger verbose logging when skipPlugins or onlyPlugins removes categories', () => { const loggerSpy = vi.spyOn(ui().logger, 'info'); @@ -366,10 +410,10 @@ describe('filterMiddleware', () => { }); }); -describe('processPlugins', () => { +describe('filterSkippedInPlugins', () => { it('should filter out skipped audits and groups', () => { expect( - processPlugins([ + filterSkippedInPlugins([ { slug: 'p1', audits: [ @@ -404,7 +448,7 @@ describe('processPlugins', () => { it('should filter out entire groups when marked as skipped', () => { expect( - processPlugins([ + filterSkippedInPlugins([ { slug: 'p1', audits: [{ slug: 'a1', isSkipped: false }], 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 c262e71b2..529b438d3 100644 --- a/packages/cli/src/lib/implementation/validate-filter-options.utils.ts +++ b/packages/cli/src/lib/implementation/validate-filter-options.utils.ts @@ -1,61 +1,69 @@ -import type { - CategoryConfig, - CategoryRef, - PluginConfig, -} from '@code-pushup/models'; +import type { PluginConfig } from '@code-pushup/models'; import { capitalize, filterItemRefsBy, pluralize, ui, } from '@code-pushup/utils'; -import type { - FilterOptionType, - FilterOptions, - Filterables, -} from './filter.model.js'; +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 - : isPluginOption(option) - ? plugins - : []; - const invalidItems = itemsToFilter.filter( - item => !validItems.some(({ slug }) => slug === item), + const skippedItemsSet = new Set(skippedItems); + const validItemsSet = new Set( + isCategoryOption(option) + ? categories.map(({ slug }) => slug) + : isPluginOption(option) + ? plugins.map(({ slug }) => slug) + : [''], ); - - const message = createValidationMessage(option, invalidItems, validItems); - - if ( - isOnlyOption(option) && - itemsToFilterSet.size > 0 && - itemsToFilterSet.size === invalidItems.length - ) { - throw new OptionValidationError(message); - } - - if (invalidItems.length > 0) { + const nonExistentItems = itemsToFilter.filter( + item => !validItemsSet.has(item) && !skippedItemsSet.has(item), + ); + const skippedValidItems = itemsToFilter.filter(item => + skippedItemsSet.has(item), + ); + 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) { + ui().logger.warning( + `The --${option} argument references skipped ${getItemType(option, skippedValidItems.length)}: ${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( ', ', )}.`, ); @@ -63,19 +71,15 @@ export function validateFilterOption( } } -export function validateFilteredCategories( +export function validateSkippedCategories( originalCategories: NonNullable, filteredCategories: NonNullable, - { - onlyCategories, - skipCategories, - verbose, - }: Pick, + verbose: boolean, ): void { const skippedCategories = originalCategories.filter( original => !filteredCategories.some(({ slug }) => slug === original.slug), ); - if (verbose) { + 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 @@ -84,22 +88,6 @@ export function validateFilteredCategories( ); }); } - const invalidArgs = [ - { option: 'onlyCategories', args: onlyCategories ?? [] }, - { option: 'skipCategories', args: skipCategories ?? [] }, - ].filter(({ args }) => - args.some(arg => skippedCategories.some(({ slug }) => slug === arg)), - ); - if (invalidArgs.length > 0) { - throw new OptionValidationError( - invalidArgs - .map( - ({ option, args }) => - `The --${option} argument references skipped categories: ${args.join(', ')}`, - ) - .join('. '), - ); - } if (filteredCategories.length === 0) { throw new OptionValidationError( `No categories remain after filtering. Removed categories: ${skippedCategories @@ -109,22 +97,6 @@ export function validateFilteredCategories( } } -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 validateFinalState( filteredItems: Filterables, originalItems: Filterables, @@ -146,9 +118,9 @@ export function validateFinalState( `Nothing to report. No plugins or categories are available after filtering. Available plugins: ${availablePlugins}. Available categories: ${availableCategories}.`, ); } - if (filteredPlugins.every(pluginHasZeroWeightRefs)) { + if (filteredPlugins.some(pluginHasZeroWeightRefs)) { throw new OptionValidationError( - `All groups in the filtered plugins have refs with zero weight. Please adjust your filters or weights.`, + 'Some groups in the filtered plugins have only zero-weight references. Please adjust your filters or weights.', ); } } @@ -159,17 +131,9 @@ export function pluginHasZeroWeightRefs( if (!plugin.groups || plugin.groups.length === 0) { return false; } - const weightMap = new Map(); - plugin.groups.forEach(group => { - group.refs.forEach(ref => { - weightMap.set(ref.slug, (weightMap.get(ref.slug) ?? 0) + ref.weight); - }); - }); - const totalWeight = plugin.audits.reduce( - (sum, audit) => sum + (weightMap.get(audit.slug) ?? 0), - 0, + return plugin.groups.some( + group => group.refs.reduce((sum, ref) => sum + ref.weight, 0) === 0, ); - return totalWeight === 0; } function isCategoryOption(option: FilterOptionType): boolean { @@ -196,7 +160,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 = @@ -205,12 +169,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 1ab84de61..1f4e4335b 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 @@ -1,9 +1,5 @@ import { describe, expect } from 'vitest'; -import type { - CategoryConfig, - CategoryRef, - PluginConfig, -} from '@code-pushup/models'; +import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import type { FilterOptionType, Filterables } from './filter.model.js'; @@ -12,11 +8,10 @@ import { createValidationMessage, getItemType, handleConflictingOptions, - isValidCategoryRef, pluginHasZeroWeightRefs, validateFilterOption, - validateFilteredCategories, validateFinalState, + validateSkippedCategories, } from './validate-filter-options.utils.js'; describe('validateFilterOption', () => { @@ -54,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); @@ -98,7 +93,7 @@ describe('validateFilterOption', () => { }, ] as CategoryConfig[], }, - { itemsToFilter, verbose: false }, + { itemsToFilter, skippedItems: [], verbose: false }, ); const logs = getLogMessages(ui().logger); expect(logs[0]).toContain(expected); @@ -114,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); }); @@ -133,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( @@ -152,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( @@ -171,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( @@ -204,7 +199,7 @@ describe('validateFilterOption', () => { }, ] as CategoryConfig[], }, - { itemsToFilter: ['c2', 'c3'], verbose: false }, + { itemsToFilter: ['c2', 'c3'], skippedItems: [], verbose: false }, ); }).toThrow( new OptionValidationError( @@ -212,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 skipped plugin: p1.', + ); + }); }); describe('createValidationMessage', () => { @@ -259,7 +273,7 @@ describe('createValidationMessage', () => { createValidationMessage( option as FilterOptionType, invalidPlugins, - validPlugins.map(slug => ({ slug })), + new Set(validPlugins), ), ).toBe(expected); }, @@ -375,12 +389,12 @@ describe('validateFinalState', () => { validateFinalState(items, items); }).toThrow( new OptionValidationError( - 'All groups in the filtered plugins have refs with zero weight. Please adjust your filters or weights.', + 'Some groups in the filtered plugins have only zero-weight references. Please adjust your filters or weights.', ), ); }); - it('should not throw an error when at least one group ref has positive weight', () => { + it('should throw an error when at least one group has all zero-weigh refs', () => { const items = { plugins: [ { @@ -398,7 +412,9 @@ describe('validateFinalState', () => { }; expect(() => { validateFinalState(items, items); - }).not.toThrow(); + }).toThrow( + 'Some groups in the filtered plugins have only zero-weight references. Please adjust your filters or weights.', + ); }); }); @@ -417,7 +433,7 @@ describe('getItemType', () => { ); }); -describe('validateFilteredCategories', () => { +describe('validateSkippedCategories', () => { const categories = [ { slug: 'c1', @@ -431,7 +447,7 @@ describe('validateFilteredCategories', () => { it('should log info when categories are removed', () => { const loggerSpy = vi.spyOn(ui().logger, 'info'); - validateFilteredCategories( + validateSkippedCategories( categories, [ { @@ -439,7 +455,7 @@ describe('validateFilteredCategories', () => { refs: [{ type: 'audit', plugin: 'p2', slug: 'a1', weight: 1 }], }, ] as NonNullable, - { verbose: true }, + true, ); expect(loggerSpy).toHaveBeenCalledWith( 'Category c1 was removed because all its refs were skipped. Affected refs: g1 (group)', @@ -448,46 +464,12 @@ describe('validateFilteredCategories', () => { it('should not log anything when categories are not removed', () => { const loggerSpy = vi.spyOn(ui().logger, 'info'); - validateFilteredCategories(categories, categories, { verbose: true }); + validateSkippedCategories(categories, categories, true); expect(loggerSpy).not.toHaveBeenCalled(); }); - it('should throw an error when onlyCategories references skipped categories', () => { - expect(() => - validateFilteredCategories(categories, [], { - onlyCategories: ['c1'], - skipCategories: [], - verbose: false, - }), - ).toThrow( - new OptionValidationError( - 'The --onlyCategories argument references skipped categories: c1', - ), - ); - }); - - it('should throw an error when skipCategories references skipped categories', () => { - expect(() => - validateFilteredCategories(categories, [], { - onlyCategories: [], - skipCategories: ['c1'], - verbose: false, - }), - ).toThrow( - new OptionValidationError( - 'The --skipCategories argument references skipped categories: c1', - ), - ); - }); - it('should throw an error when no categories remain after filtering', () => { - expect(() => - validateFilteredCategories(categories, [], { - onlyCategories: [], - skipCategories: [], - verbose: false, - }), - ).toThrow( + expect(() => validateSkippedCategories(categories, [], false)).toThrow( new OptionValidationError( 'No categories remain after filtering. Removed categories: c1, c2', ), @@ -495,68 +477,8 @@ describe('validateFilteredCategories', () => { }); }); -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('pluginHasZeroWeightRefs', () => { - it('should return true if all refs have zero weight', () => { + it('should return true if any group has all refs with zero weight', () => { expect( pluginHasZeroWeightRefs({ groups: [ @@ -567,8 +489,14 @@ describe('pluginHasZeroWeightRefs', () => { { slug: 'a2', weight: 0 }, ], }, + { + slug: 'g2', + refs: [ + { slug: 'a3', weight: 1 }, + { slug: 'a4', weight: 0 }, + ], + }, ], - audits: [{ slug: 'a1' }, { slug: 'a2' }], } as PluginConfig), ).toBe(true); }); @@ -584,18 +512,21 @@ describe('pluginHasZeroWeightRefs', () => { { slug: 'a2', weight: 0 }, ], }, + { + slug: 'g2', + refs: [ + { slug: 'a3', weight: 1 }, + { slug: 'a4', weight: 0 }, + ], + }, ], - audits: [{ slug: 'a1' }, { slug: 'a2' }], } as PluginConfig), ).toBe(false); }); it('should return false if there are no groups', () => { - expect( - pluginHasZeroWeightRefs({ - groups: undefined, - audits: [{ slug: 'a1' }], - } as PluginConfig), - ).toBe(false); + 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/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts index 4e2669a14..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,20 +18,57 @@ describe('lighthousePlugin-config-object', () => { ]); }); - it('should mark audits in onlyAudits as not skipped', () => { - const pluginConfig = lighthousePlugin('https://code-pushup-portal.com', { - onlyAudits: ['first-contentful-paint'], - }); + 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(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); - expect( - pluginConfig.audits.find(({ slug }) => slug === 'first-contentful-paint'), - ).toEqual( - expect.objectContaining({ - isSkipped: false, - }), - ); - }); + 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 mark groups referencing audits in onlyAudits as not skipped', () => { const pluginConfig = lighthousePlugin('https://code-pushup-portal.com', { From 581130f0bfd4e5a4cd230510470401ddb3dc00d2 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Fri, 10 Jan 2025 08:24:44 -0500 Subject: [PATCH 5/5] feat(cli): implement filter utilities --- .../validate-filter-options.utils.ts | 20 +++++++++++-------- ...validate-filter-options.utils.unit.test.ts | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) 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 529b438d3..60048f0a6 100644 --- a/packages/cli/src/lib/implementation/validate-filter-options.utils.ts +++ b/packages/cli/src/lib/implementation/validate-filter-options.utils.ts @@ -19,21 +19,23 @@ export function validateFilterOption( verbose, }: { itemsToFilter: string[]; skippedItems: string[]; verbose: boolean }, ): void { + const validItems = isCategoryOption(option) + ? categories.map(({ slug }) => slug) + : isPluginOption(option) + ? plugins.map(({ slug }) => slug) + : []; + const itemsToFilterSet = new Set(itemsToFilter); const skippedItemsSet = new Set(skippedItems); - const validItemsSet = new Set( - isCategoryOption(option) - ? categories.map(({ slug }) => slug) - : isPluginOption(option) - ? plugins.map(({ slug }) => slug) - : [''], - ); + const validItemsSet = new Set(validItems); + const nonExistentItems = itemsToFilter.filter( item => !validItemsSet.has(item) && !skippedItemsSet.has(item), ); const skippedValidItems = itemsToFilter.filter(item => skippedItemsSet.has(item), ); + if (nonExistentItems.length > 0) { const message = createValidationMessage( option, @@ -50,8 +52,10 @@ export function validateFilterOption( 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 skipped ${getItemType(option, skippedValidItems.length)}: ${skippedValidItems.join(', ')}.`, + `The --${option} argument references ${prefix} ${item}: ${skippedValidItems.join(', ')}.`, ); } if (isPluginOption(option) && categories.length > 0 && verbose) { 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 1f4e4335b..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 @@ -223,7 +223,7 @@ describe('validateFilterOption', () => { ); const logs = getLogMessages(ui().logger); expect(logs[0]).toContain( - 'The --skipPlugins argument references skipped plugin: p1.', + 'The --skipPlugins argument references a skipped plugin: p1.', ); }); });