diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 849ca0450..15ba02ccc 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -20,7 +20,9 @@ import { } from './packages/plugin-jsdocs/src/lib/constants.js'; import { filterGroupsByOnlyAudits } from './packages/plugin-jsdocs/src/lib/utils.js'; import lighthousePlugin, { + type LighthouseUrls, lighthouseGroupRef, + mergeLighthouseCategories, } from './packages/plugin-lighthouse/src/index.js'; import typescriptPlugin, { type TypescriptPluginOptions, @@ -135,11 +137,14 @@ export const jsPackagesCoreConfig = async (): Promise => ({ }); export const lighthouseCoreConfig = async ( - url: string, -): Promise => ({ - plugins: [await lighthousePlugin(url)], - categories: lighthouseCategories, -}); + urls: LighthouseUrls, +): Promise => { + const lhPlugin = await lighthousePlugin(urls); + return { + plugins: [lhPlugin], + categories: mergeLighthouseCategories(lhPlugin, lighthouseCategories), + }; +}; export const jsDocsCoreConfig = ( config: JsDocsPluginConfig | string[], diff --git a/e2e/plugin-lighthouse-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/plugin-lighthouse-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index ccf448795..2af5b2b75 100644 --- a/e2e/plugin-lighthouse-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/plugin-lighthouse-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -77,6 +77,12 @@ exports[`PLUGIN collect report with lighthouse-plugin NPM package > should run p "title": "Document has a valid \`hreflang\`", }, ], + "context": { + "urlCount": 1, + "weights": { + "1": 1, + }, + }, "groups": [ { "refs": [ diff --git a/packages/cli/src/lib/implementation/filter.middleware.ts b/packages/cli/src/lib/implementation/filter.middleware.ts index 1559de69b..2afe522ee 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.ts @@ -116,10 +116,13 @@ function applyPluginFilters( options: Pick, ): CoreConfig['plugins'] { const { skipPlugins = [], onlyPlugins = [] } = options; - const filteredPlugins = filterPluginsFromCategories({ - categories, - plugins, - }); + const filteredPlugins = + onlyPlugins.length === 0 + ? filterPluginsFromCategories({ + categories, + plugins, + }) + : plugins; if (skipPlugins.length === 0 && onlyPlugins.length === 0) { return filteredPlugins; } 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 4babe4dc3..55bfda28d 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts @@ -414,6 +414,44 @@ describe('filterMiddleware', () => { ), ); }); + + it('should allow onlyPlugins to include plugins not referenced by categories', () => { + const { plugins } = filterMiddleware({ + plugins: [ + { + slug: 'p1', + audits: [{ slug: 'a1-p1', isSkipped: false }], + groups: [ + { + slug: 'g1-p1', + refs: [{ slug: 'a1-p1', weight: 1 }], + isSkipped: false, + }, + ], + }, + { + slug: 'p2', + audits: [{ slug: 'a1-p2', isSkipped: false }], + groups: [ + { + slug: 'g1-p2', + refs: [{ slug: 'a1-p2', weight: 1 }], + isSkipped: false, + }, + ], + }, + ] as PluginConfig[], + categories: [ + { + slug: 'c1', + refs: [{ type: 'group', plugin: 'p1', slug: 'g1-p1', weight: 1 }], + }, + ] as CategoryConfig[], + onlyPlugins: ['p2'], + }); + + expect(plugins.map(plugin => plugin.slug)).toStrictEqual(['p2']); + }); }); describe('filterSkippedInPlugins', () => { diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index fcef50523..32754e6dc 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -67,8 +67,10 @@ export { } from './lib/persist-config.js'; export { pluginConfigSchema, + pluginContextSchema, pluginMetaSchema, type PluginConfig, + type PluginContext, type PluginMeta, } from './lib/plugin-config.js'; export { diff --git a/packages/models/src/lib/plugin-config.ts b/packages/models/src/lib/plugin-config.ts index 962dd819c..b977e7ec4 100644 --- a/packages/models/src/lib/plugin-config.ts +++ b/packages/models/src/lib/plugin-config.ts @@ -10,6 +10,12 @@ import { import { errorItems, hasMissingStrings } from './implementation/utils.js'; import { runnerConfigSchema, runnerFunctionSchema } from './runner-config.js'; +export const pluginContextSchema = z + .record(z.unknown()) + .optional() + .describe('Plugin-specific context data for helpers'); +export type PluginContext = z.infer; + export const pluginMetaSchema = packageVersionSchema() .merge( metaSchema({ @@ -31,6 +37,7 @@ export const pluginDataSchema = z.object({ runner: z.union([runnerConfigSchema, runnerFunctionSchema]), audits: pluginAuditsSchema, groups: groupsSchema, + context: pluginContextSchema, }); type PluginData = z.infer; diff --git a/packages/plugin-lighthouse/README.md b/packages/plugin-lighthouse/README.md index 196fc3e74..d0ff7562a 100644 --- a/packages/plugin-lighthouse/README.md +++ b/packages/plugin-lighthouse/README.md @@ -117,6 +117,100 @@ export default { }; ``` +## Multiple URLs + +The Lighthouse plugin supports running audits against multiple URLs in a single invocation. To do this, provide an array of URLs as the first argument to the plugin: + +```ts +import lighthousePlugin from '@code-pushup/lighthouse-plugin'; + +export default { + // ... + plugins: [ + // ... + await lighthousePlugin(['https://example.com', 'https://example.com/contact']), + ], +}; +``` + +### Assigning weights to URLs + +You can assign custom weights to URLs by passing an object instead of an array. This is useful when some pages are more important than others (e.g., your homepage vs. a contact page). The keys are URLs, and the values are their weights. + +URLs with higher weights contribute more to the overall category scores. For example, a URL with weight 2 has twice the influence of a URL with weight 1. + +```ts +import lighthousePlugin from '@code-pushup/lighthouse-plugin'; + +export default { + // ... + plugins: [ + // ... + await lighthousePlugin({ + 'https://example.com': 2, + 'https://example.com/contact': 1, + }) + ]; +}; +``` + +### Categories with multiple URLs + +When running Lighthouse against multiple URLs, use the `mergeLighthouseCategories` utility to ensure categories are correctly expanded and results are aggregated per URL. + +#### Basic usage + +```ts +import lighthousePlugin, { mergeLighthouseCategories } from '@code-pushup/lighthouse-plugin'; + +const lhPlugin = await lighthousePlugin(urls); + +export default { + plugins: [ + // ... + lhPlugin, + ], + categories: [ + // ... + ...mergeLighthouseCategories(lhPlugin), + ], +}; +``` + +#### Custom categories + +If you provide custom categories, you can reference both groups and audits as usual. The merging utility will expand each referenced group or audit for every URL, assigning the correct per-URL weight. + +```ts +import lighthousePlugin, { lighthouseAuditRef, lighthouseGroupRef, mergeLighthouseCategories } from '@code-pushup/lighthouse-plugin'; + +const lhPlugin = await lighthousePlugin(urls); + +export default { + // ... + plugins: [ + // ... + lhPlugin, + ], + categories: [ + // ... + ...mergeLighthouseCategories(lhPlugin, [ + { + slug: 'performance', + title: 'Performance', + refs: [lighthouseGroupRef('performance'), lighthouseAuditRef('first-contentful-paint', 2)], + }, + ]), + ], +}; +``` + +### Behavior Summary + +- **No categories**: The plugin auto-generates categories from the plugin's default Lighthouse groups. +- **Custom categories**: The plugin expands all referenced audits and groups for each URL, applying appropriate weights. +- **Empty array** (`categories: []`): No categories are created or expanded, which is useful when you only want audit data. + ## Flags The plugin accepts an optional second argument, `flags`. diff --git a/packages/plugin-lighthouse/src/index.ts b/packages/plugin-lighthouse/src/index.ts index 5e454f03b..cbaeb2af2 100644 --- a/packages/plugin-lighthouse/src/index.ts +++ b/packages/plugin-lighthouse/src/index.ts @@ -6,11 +6,12 @@ export { LIGHTHOUSE_PLUGIN_SLUG, LIGHTHOUSE_OUTPUT_PATH, } from './lib/constants.js'; -export { - lighthouseAuditRef, - lighthouseGroupRef, - type LighthouseGroupSlugs, -} from './lib/utils.js'; -export type { LighthouseOptions } from './lib/types.js'; +export { lighthouseAuditRef, lighthouseGroupRef } from './lib/utils.js'; +export type { + LighthouseGroupSlug, + LighthouseOptions, + LighthouseUrls, +} from './lib/types.js'; export { lighthousePlugin } from './lib/lighthouse-plugin.js'; export default lighthousePlugin; +export { mergeLighthouseCategories } from './lib/merge-categories.js'; diff --git a/packages/plugin-lighthouse/src/lib/constants.ts b/packages/plugin-lighthouse/src/lib/constants.ts index 5d3cce15c..2a79ab3f3 100644 --- a/packages/plugin-lighthouse/src/lib/constants.ts +++ b/packages/plugin-lighthouse/src/lib/constants.ts @@ -10,3 +10,13 @@ export const LIGHTHOUSE_OUTPUT_PATH = path.join( DEFAULT_PERSIST_OUTPUT_DIR, LIGHTHOUSE_PLUGIN_SLUG, ); + +export const LIGHTHOUSE_GROUP_SLUGS = [ + 'performance', + 'accessibility', + 'best-practices', + 'seo', + 'pwa', +] as const; + +export const SINGLE_URL_THRESHOLD = 1; diff --git a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts index ba452f94f..cb5184a81 100644 --- a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts +++ b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts @@ -2,26 +2,24 @@ import { createRequire } from 'node:module'; import type { PluginConfig } from '@code-pushup/models'; import { LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; import { normalizeFlags } from './normalize-flags.js'; -import { - LIGHTHOUSE_GROUPS, - LIGHTHOUSE_NAVIGATION_AUDITS, -} from './runner/constants.js'; +import { normalizeUrlInput, processAuditsAndGroups } from './processing.js'; import { createRunnerFunction } from './runner/runner.js'; -import type { LighthouseOptions } from './types.js'; -import { markSkippedAuditsAndGroups } from './utils.js'; +import type { LighthouseOptions, LighthouseUrls } from './types.js'; export function lighthousePlugin( - url: string, + urls: LighthouseUrls, flags?: LighthouseOptions, ): PluginConfig { const { skipAudits, onlyAudits, onlyCategories, ...unparsedFlags } = normalizeFlags(flags ?? {}); - const { audits, groups } = markSkippedAuditsAndGroups( - LIGHTHOUSE_NAVIGATION_AUDITS, - LIGHTHOUSE_GROUPS, - { skipAudits, onlyAudits, onlyCategories }, - ); + const { urls: normalizedUrls, context } = normalizeUrlInput(urls); + + const { audits, groups } = processAuditsAndGroups(normalizedUrls, { + skipAudits, + onlyAudits, + onlyCategories, + }); const packageJson = createRequire(import.meta.url)( '../../package.json', @@ -35,11 +33,12 @@ export function lighthousePlugin( icon: 'lighthouse', audits, groups, - runner: createRunnerFunction(url, { + runner: createRunnerFunction(normalizedUrls, { skipAudits, onlyAudits, onlyCategories, ...unparsedFlags, }), + context, }; } 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 3ac6a8419..8ea1735b1 100644 --- a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts @@ -18,6 +18,39 @@ describe('lighthousePlugin-config-object', () => { ]); }); + it('should create valid plugin config with multiple URLs', () => { + const pluginConfig = lighthousePlugin([ + 'https://code-pushup-portal.com', + 'https://code-pushup-portal.com/about', + ]); + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + + const { audits, groups } = pluginConfig; + expect(audits.length).toBeGreaterThan(100); + expect(groups).toStrictEqual([ + expect.objectContaining({ slug: 'performance-1' }), + expect.objectContaining({ slug: 'accessibility-1' }), + expect.objectContaining({ slug: 'best-practices-1' }), + expect.objectContaining({ slug: 'seo-1' }), + expect.objectContaining({ slug: 'performance-2' }), + expect.objectContaining({ slug: 'accessibility-2' }), + expect.objectContaining({ slug: 'best-practices-2' }), + expect.objectContaining({ slug: 'seo-2' }), + ]); + }); + + it('should generate context for multiple URLs', () => { + const pluginConfig = lighthousePlugin({ + 'https://code-pushup-portal.com': 2, + 'https://code-pushup-portal.com/about': 1, + }); + + expect(pluginConfig.context).toStrictEqual({ + urlCount: 2, + weights: { 1: 2, 2: 1 }, + }); + }); + it.each([ [ { onlyAudits: ['first-contentful-paint'] }, diff --git a/packages/plugin-lighthouse/src/lib/merge-categories.ts b/packages/plugin-lighthouse/src/lib/merge-categories.ts new file mode 100644 index 000000000..e3f8f795d --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/merge-categories.ts @@ -0,0 +1,159 @@ +import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models'; +import { LIGHTHOUSE_GROUP_SLUGS, LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; +import { orderSlug, shouldExpandForUrls } from './processing.js'; +import { LIGHTHOUSE_GROUPS } from './runner/constants.js'; +import type { LighthouseContext, LighthouseGroupSlug } from './types.js'; +import { isLighthouseGroupSlug } from './utils.js'; + +/** + * Expands and aggregates categories for multi-URL Lighthouse runs. + * + * - If user categories are provided, expands all refs (groups and audits) for each URL. + * - If not, generates categories from plugin groups only. + * - Assigns per-URL weights with correct precedence. + * + * @public + * @param plugin - {@link PluginConfig} object with groups and context + * @param categories - {@link CategoryConfig} optional user-defined categories + * @returns {CategoryConfig[]} - expanded and agregated categories + * + * @example + * const lhPlugin = await lighthousePlugin(urls); + * const lhCoreConfig = { + * plugins: [lhPlugin], + * categories: mergeLighthouseCategories(lhPlugin), + * }; + */ +export function mergeLighthouseCategories( + plugin: Pick, + categories?: CategoryConfig[], +): CategoryConfig[] { + if (!plugin.groups || plugin.groups.length === 0) { + return categories ?? []; + } + validateContext(plugin.context); + if (!categories) { + return createCategories(plugin.groups, plugin.context); + } + return expandCategories(categories, plugin.context); +} + +function createCategories( + groups: Group[], + context: LighthouseContext, +): CategoryConfig[] { + if (!shouldExpandForUrls(context.urlCount)) { + return []; + } + return extractGroupSlugs(groups).map(slug => + createAggregatedCategory(slug, context), + ); +} + +function expandCategories( + categories: CategoryConfig[], + context: LighthouseContext, +): CategoryConfig[] { + if (!shouldExpandForUrls(context.urlCount)) { + return categories; + } + return categories.map(category => + expandAggregatedCategory(category, context), + ); +} + +/** + * Creates a category config for a Lighthouse group, expanding it for each URL. + * Only used when user categories are not provided. + */ +export function createAggregatedCategory( + groupSlug: LighthouseGroupSlug, + context: LighthouseContext, +): CategoryConfig { + const group = LIGHTHOUSE_GROUPS.find(({ slug }) => slug === groupSlug); + if (!group) { + const availableSlugs = LIGHTHOUSE_GROUP_SLUGS.join(', '); + throw new Error( + `Invalid Lighthouse group slug: "${groupSlug}". Available groups: ${availableSlugs}`, + ); + } + return { + slug: group.slug, + title: group.title, + ...(group.description && { description: group.description }), + refs: Array.from({ length: context.urlCount }, (_, i) => ({ + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: shouldExpandForUrls(context.urlCount) + ? orderSlug(group.slug, i) + : group.slug, + type: 'group', + weight: resolveWeight(context.weights, i), + })), + }; +} + +/** + * Expands all refs (groups and audits) in a user-defined category for each URL. + * Used when user categories are provided. + */ +export function expandAggregatedCategory( + category: CategoryConfig, + context: LighthouseContext, +): CategoryConfig { + return { + ...category, + refs: category.refs.flatMap(ref => { + if (ref.plugin === LIGHTHOUSE_PLUGIN_SLUG) { + return Array.from({ length: context.urlCount }, (_, i) => ({ + ...ref, + slug: shouldExpandForUrls(context.urlCount) + ? orderSlug(ref.slug, i) + : ref.slug, + weight: resolveWeight(context.weights, i, ref.weight), + })); + } + return [ref]; + }), + }; +} + +/** + * Extracts unique, unsuffixed group slugs from a list of groups. + * Useful for deduplicating and normalizing group slugs when generating categories. + */ +export function extractGroupSlugs(groups: Group[]): LighthouseGroupSlug[] { + const slugs = groups.map(({ slug }) => slug.replace(/-\d+$/, '')); + return [...new Set(slugs)].filter(isLighthouseGroupSlug); +} + +export class ContextValidationError extends Error { + constructor(message: string) { + super(`Invalid Lighthouse context: ${message}`); + } +} + +export function validateContext( + context: PluginConfig['context'], +): asserts context is LighthouseContext { + if (!context || typeof context !== 'object') { + throw new ContextValidationError('must be an object'); + } + const { urlCount, weights } = context; + if (typeof urlCount !== 'number' || urlCount < 0) { + throw new ContextValidationError('urlCount must be a non-negative number'); + } + if (!weights || typeof weights !== 'object') { + throw new ContextValidationError('weights must be an object'); + } + if (Object.keys(weights).length !== urlCount) { + throw new ContextValidationError('weights count must match urlCount'); + } +} + +function resolveWeight( + weights: LighthouseContext['weights'], + index: number, + userDefinedWeight?: number, +): number { + return weights[index + 1] ?? userDefinedWeight ?? 1; +} diff --git a/packages/plugin-lighthouse/src/lib/merge-categories.unit.test.ts b/packages/plugin-lighthouse/src/lib/merge-categories.unit.test.ts new file mode 100644 index 000000000..a3bfb2e28 --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/merge-categories.unit.test.ts @@ -0,0 +1,864 @@ +import { describe, expect, it } from 'vitest'; +import type { CategoryConfig } from '@code-pushup/models'; +import { LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; +import { + ContextValidationError, + createAggregatedCategory, + expandAggregatedCategory, + extractGroupSlugs, + mergeLighthouseCategories, + validateContext, +} from './merge-categories.js'; + +describe('mergeLighthouseCategories', () => { + const mockMultiUrlPlugin = { + groups: [ + { + slug: 'performance-1', + title: 'Performance (example.com)', + refs: [{ slug: 'first-contentful-paint-1', weight: 1 }], + }, + { + slug: 'accessibility-1', + title: 'Accessibility (example.com)', + refs: [{ slug: 'color-contrast-1', weight: 1 }], + }, + { + slug: 'performance-2', + title: 'Performance (example.com/about)', + refs: [{ slug: 'first-contentful-paint-2', weight: 1 }], + }, + { + slug: 'accessibility-2', + title: 'Accessibility (example.com/about)', + refs: [{ slug: 'color-contrast-2', weight: 1 }], + }, + ], + context: { + urlCount: 2, + weights: { 1: 1, 2: 1 }, + }, + }; + + const mockUserCategories: CategoryConfig[] = [ + { + slug: 'performance', + title: 'Website Performance', + description: 'Measures how fast your website loads', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 2, + }, + ], + }, + { + slug: 'a11y', + title: 'Accessibility', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'accessibility', + weight: 1, + }, + ], + }, + ]; + + describe('with no groups', () => { + it('should return empty array when no groups and no categories provided', () => { + expect(mergeLighthouseCategories({ groups: undefined })).toEqual([]); + }); + + it('should return provided categories when no groups provided', () => { + expect( + mergeLighthouseCategories({ groups: undefined }, mockUserCategories), + ).toEqual(mockUserCategories); + }); + }); + + describe('with single URL', () => { + const plugin = { + groups: [ + { + slug: 'performance', + title: 'Performance', + refs: [{ slug: 'first-contentful-paint', weight: 1 }], + }, + { + slug: 'accessibility', + title: 'Accessibility', + refs: [{ slug: 'color-contrast', weight: 1 }], + }, + ], + context: { + urlCount: 1, + weights: { 1: 1 }, + }, + }; + + it('should return empty array when no categories provided', () => { + expect(mergeLighthouseCategories(plugin)).toEqual([]); + }); + + it('should return provided categories unchanged', () => { + expect(mergeLighthouseCategories(plugin, mockUserCategories)).toEqual( + mockUserCategories, + ); + }); + }); + + describe('with multiple URLs', () => { + it('should create default aggregated categories when no categories provided', () => { + const result = mergeLighthouseCategories(mockMultiUrlPlugin); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + slug: 'performance', + title: 'Performance', + refs: [ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + type: 'group', + weight: 1, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + type: 'group', + weight: 1, + }, + ], + }); + expect(result[1]).toEqual({ + slug: 'accessibility', + title: 'Accessibility', + description: expect.any(String), + refs: [ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'accessibility-1', + type: 'group', + weight: 1, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'accessibility-2', + type: 'group', + weight: 1, + }, + ], + }); + }); + + it('should expand user-provided categories', () => { + const result = mergeLighthouseCategories( + mockMultiUrlPlugin, + mockUserCategories, + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + slug: 'performance', + title: 'Website Performance', + description: 'Measures how fast your website loads', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 1, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 1, + }, + ], + }); + expect(result[1]).toEqual({ + slug: 'a11y', + title: 'Accessibility', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'accessibility-1', + weight: 1, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'accessibility-2', + weight: 1, + }, + ], + }); + }); + + it('should handle mixed group and audit refs', () => { + expect( + mergeLighthouseCategories(mockMultiUrlPlugin, [ + { + slug: 'mixed-performance', + title: 'Mixed Performance', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 1, + }, + { + type: 'audit', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint', + weight: 2, + }, + ], + }, + ])[0]?.refs, + ).toEqual([ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 1, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 1, + }, + { + type: 'audit', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint-1', + weight: 1, + }, + { + type: 'audit', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint-2', + weight: 1, + }, + ]); + }); + + it('should preserve non-Lighthouse refs unchanged', () => { + expect( + mergeLighthouseCategories(mockMultiUrlPlugin, [ + { + slug: 'mixed-category', + title: 'Mixed Category', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 1, + }, + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + { + type: 'audit', + plugin: 'other-plugin', + slug: 'some-audit', + weight: 1, + }, + ], + }, + ])[0]?.refs, + ).toEqual([ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 1, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 1, + }, + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + { + type: 'audit', + plugin: 'other-plugin', + slug: 'some-audit', + weight: 1, + }, + ]); + }); + + it('should handle categories without Lighthouse refs', () => { + const categories: CategoryConfig[] = [ + { + slug: 'code-quality', + title: 'Code Quality', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + { + type: 'group', + plugin: 'typescript', + slug: 'type-issues', + weight: 1, + }, + ], + }, + ]; + + expect( + mergeLighthouseCategories(mockMultiUrlPlugin, categories)[0], + ).toEqual(categories[0]); + }); + + it('should preserve all category properties', () => { + expect( + mergeLighthouseCategories(mockMultiUrlPlugin, [ + { + slug: 'performance', + title: 'Performance', + description: 'Website performance metrics', + docsUrl: 'https://docs.example.com/performance', + isBinary: true, + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 1, + }, + ], + }, + ])[0], + ).toEqual({ + slug: 'performance', + title: 'Performance', + description: 'Website performance metrics', + docsUrl: 'https://docs.example.com/performance', + isBinary: true, + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 1, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 1, + }, + ], + }); + }); + }); + + describe('URL count detection', () => { + it('should handle 3 URLs correctly', () => { + const plugin = { + groups: [ + { slug: 'performance-1', title: 'Performance 1', refs: [] }, + { slug: 'performance-2', title: 'Performance 2', refs: [] }, + { slug: 'performance-3', title: 'Performance 3', refs: [] }, + ], + context: { urlCount: 3, weights: { 1: 1, 2: 1, 3: 1 } }, + }; + + const categories: CategoryConfig[] = [ + { + slug: 'performance', + title: 'Performance', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 1, + }, + ], + }, + ]; + + const result = mergeLighthouseCategories(plugin, categories); + + expect(result[0]?.refs).toHaveLength(3); + expect(result[0]?.refs.map(({ slug }) => slug)).toEqual([ + 'performance-1', + 'performance-2', + 'performance-3', + ]); + }); + + it('should filter out invalid Lighthouse groups', () => { + const result = mergeLighthouseCategories({ + groups: [ + { slug: 'performance-1', title: 'Performance 1', refs: [] }, + { slug: 'invalid-group-1', title: 'Invalid Group 1', refs: [] }, + { slug: 'accessibility-1', title: 'Accessibility 1', refs: [] }, + { slug: 'another-invalid-1', title: 'Another Invalid 1', refs: [] }, + { slug: 'performance-2', title: 'Performance 2', refs: [] }, + { slug: 'invalid-group-2', title: 'Invalid Group 2', refs: [] }, + { slug: 'accessibility-2', title: 'Accessibility 2', refs: [] }, + { slug: 'another-invalid-2', title: 'Another Invalid 2', refs: [] }, + ], + context: { urlCount: 2, weights: { 1: 1, 2: 1 } }, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + slug: 'performance', + title: 'Performance', + refs: [ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + type: 'group', + weight: 1, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + type: 'group', + weight: 1, + }, + ], + }); + expect(result[1]).toEqual({ + slug: 'accessibility', + title: 'Accessibility', + description: expect.any(String), + refs: [ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'accessibility-1', + type: 'group', + weight: 1, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'accessibility-2', + type: 'group', + weight: 1, + }, + ], + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty categories array', () => { + expect(mergeLighthouseCategories(mockMultiUrlPlugin, [])).toEqual([]); + }); + + it('should handle plugin with empty groups array', () => { + expect( + mergeLighthouseCategories( + { groups: [], context: { urlCount: 0, weights: {} } }, + mockUserCategories, + ), + ).toEqual(mockUserCategories); + }); + + it('should handle categories with empty refs', () => { + const category: CategoryConfig[] = [ + { + slug: 'empty-category', + title: 'Empty Category', + refs: [], + }, + ]; + + expect( + mergeLighthouseCategories(mockMultiUrlPlugin, category)[0], + ).toEqual(category[0]); + }); + }); +}); + +describe('extractGroupSlugs', () => { + it('should extract unique base slugs from ordered groups', () => { + const groups = [ + { slug: 'performance-1', title: 'Performance 1', refs: [] }, + { slug: 'performance-2', title: 'Performance 2', refs: [] }, + { slug: 'accessibility-1', title: 'Accessibility 1', refs: [] }, + { slug: 'accessibility-2', title: 'Accessibility 2', refs: [] }, + ]; + expect(extractGroupSlugs(groups)).toEqual(['performance', 'accessibility']); + }); + + it('should handle non-ordered groups', () => { + const groups = [ + { slug: 'performance', title: 'Performance', refs: [] }, + { slug: 'accessibility', title: 'Accessibility', refs: [] }, + ]; + expect(extractGroupSlugs(groups)).toEqual(['performance', 'accessibility']); + }); + + it('should handle mixed ordered and non-ordered groups', () => { + const groups = [ + { slug: 'performance', title: 'Performance', refs: [] }, + { slug: 'accessibility-1', title: 'Accessibility 1', refs: [] }, + { slug: 'accessibility-2', title: 'Accessibility 2', refs: [] }, + ]; + expect(extractGroupSlugs(groups)).toEqual(['performance', 'accessibility']); + }); + + it('should return unique slugs only', () => { + const groups = [ + { slug: 'performance-1', title: 'Performance 1', refs: [] }, + { slug: 'performance-2', title: 'Performance 2', refs: [] }, + { slug: 'performance-3', title: 'Performance 3', refs: [] }, + ]; + expect(extractGroupSlugs(groups)).toEqual(['performance']); + }); + + it('should handle empty groups array', () => { + expect(extractGroupSlugs([])).toEqual([]); + }); +}); + +describe('createAggregatedCategory', () => { + it("should create category with Lighthouse groups' refs", () => { + expect( + createAggregatedCategory('performance', { + urlCount: 2, + weights: { 1: 1, 2: 1 }, + }), + ).toEqual({ + slug: 'performance', + title: 'Performance', + refs: [ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + type: 'group', + weight: 1, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + type: 'group', + weight: 1, + }, + ], + }); + }); + + it('should throw error for unknown group slug', () => { + expect(() => + // @ts-expect-error A safeguard test case; the error should never be thrown + createAggregatedCategory('unknown-group', { + urlCount: 1, + weights: { 1: 1 }, + }), + ).toThrow( + 'Invalid Lighthouse group slug: "unknown-group". Available groups: performance, accessibility, best-practices, seo', + ); + }); + + it('should handle single URL', () => { + const result = createAggregatedCategory('accessibility', { + urlCount: 1, + weights: { 1: 1 }, + }); + + expect(result.refs).toHaveLength(1); + expect(result.refs[0]?.slug).toBe('accessibility'); + }); + + it('should handle multiple URLs', () => { + const result = createAggregatedCategory('seo', { + urlCount: 3, + weights: { 1: 1, 2: 1, 3: 1 }, + }); + + expect(result.refs).toHaveLength(3); + expect(result.refs.map(ref => ref.slug)).toEqual([ + 'seo-1', + 'seo-2', + 'seo-3', + ]); + }); +}); + +describe('expandAggregatedCategory', () => { + it('should expand Lighthouse plugin refs only', () => { + expect( + expandAggregatedCategory( + { + slug: 'mixed-category', + title: 'Mixed Category', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 2, + }, + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + ], + }, + { + urlCount: 2, + weights: { 1: 2, 2: 2 }, + }, + ).refs, + ).toEqual([ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 2, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 2, + }, + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + ]); + }); + + it('should expand both group and audit refs', () => { + expect( + expandAggregatedCategory( + { + slug: 'mixed-refs', + title: 'Mixed Refs', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 1, + }, + { + type: 'audit', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint', + weight: 3, + }, + ], + }, + { + urlCount: 2, + weights: { 1: 3, 2: 1 }, + }, + ).refs, + ).toEqual([ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 3, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 1, + }, + { + type: 'audit', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint-1', + weight: 3, + }, + { + type: 'audit', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint-2', + weight: 1, + }, + ]); + }); + + it('should preserve category properties', () => { + const category: CategoryConfig = { + slug: 'performance', + title: 'Performance', + description: 'Website performance metrics', + docsUrl: 'https://docs.example.com', + isBinary: true, + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 1, + }, + ], + }; + expect( + expandAggregatedCategory(category, { urlCount: 1, weights: { 1: 1 } }), + ).toEqual(category); + }); + + it('should handle empty refs array', () => { + const category: CategoryConfig = { + slug: 'empty-category', + title: 'Empty Category', + refs: [], + }; + + expect( + expandAggregatedCategory(category, { + urlCount: 2, + weights: { 1: 1, 2: 1 }, + }), + ).toEqual(category); + }); + + it('should handle categories with only non-Lighthouse refs', () => { + const category: CategoryConfig = { + slug: 'eslint-category', + title: 'ESLint Category', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + { type: 'audit', plugin: 'typescript', slug: 'type-check', weight: 1 }, + ], + }; + + expect( + expandAggregatedCategory(category, { + urlCount: 3, + weights: { 1: 1, 2: 1, 3: 1 }, + }), + ).toEqual(category); + }); + + it('should prioritize URL weights over user-defined category weights', () => { + expect( + expandAggregatedCategory( + { + slug: 'performance', + title: 'Performance', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 2, + }, + ], + }, + { urlCount: 2, weights: { 1: 3, 2: 5 } }, + ).refs, + ).toEqual([ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 3, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 5, + }, + ]); + }); + + it('should fall back to user-defined weight when URL weight is missing', () => { + expect( + expandAggregatedCategory( + { + slug: 'performance', + title: 'Performance', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 7, + }, + ], + }, + { urlCount: 2, weights: { 1: 3 } }, + ).refs, + ).toEqual([ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + weight: 3, + }, + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + weight: 7, + }, + ]); + }); + + it('should not add suffixes for single URL but preserve weights', () => { + expect( + expandAggregatedCategory( + { + slug: 'performance', + title: 'Performance', + refs: [ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 1, + }, + ], + }, + { urlCount: 1, weights: { 1: 5 } }, + ).refs, + ).toEqual([ + { + type: 'group', + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance', + weight: 5, + }, + ]); + }); +}); + +describe('validateContext', () => { + it('should throw error for invalid context (undefined)', () => { + expect(() => validateContext(undefined)).toThrow( + new ContextValidationError('must be an object'), + ); + }); + + it('should throw error for invalid context (missing urlCount)', () => { + expect(() => validateContext({ weights: {} })).toThrow( + new ContextValidationError('urlCount must be a non-negative number'), + ); + }); + + it('should throw error for invalid context (negative urlCount)', () => { + expect(() => validateContext({ urlCount: -1, weights: {} })).toThrow( + new ContextValidationError('urlCount must be a non-negative number'), + ); + }); + + it('should throw error for invalid context (missing weights)', () => { + expect(() => validateContext({ urlCount: 2 })).toThrow( + new ContextValidationError('weights must be an object'), + ); + }); + + it('should accept valid context', () => { + expect(() => + validateContext({ urlCount: 2, weights: { 1: 1, 2: 1 } }), + ).not.toThrow(); + }); +}); diff --git a/packages/plugin-lighthouse/src/lib/processing.ts b/packages/plugin-lighthouse/src/lib/processing.ts new file mode 100644 index 000000000..d196a7edb --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/processing.ts @@ -0,0 +1,121 @@ +import type { Audit, Group } from '@code-pushup/models'; +import { SINGLE_URL_THRESHOLD } from './constants.js'; +import { + LIGHTHOUSE_GROUPS, + LIGHTHOUSE_NAVIGATION_AUDITS, +} from './runner/constants.js'; +import type { LighthouseContext, LighthouseUrls } from './types.js'; +import { type FilterOptions, markSkippedAuditsAndGroups } from './utils.js'; + +export function orderSlug(slug: string, index: number): string { + return `${slug}-${index + 1}`; +} + +export function shouldExpandForUrls(urlCount: number): boolean { + return urlCount > SINGLE_URL_THRESHOLD; +} + +export function normalizeUrlInput(input: LighthouseUrls): { + urls: string[]; + context: LighthouseContext; +} { + const urls = extractUrls(input); + const weights = Object.fromEntries( + urls.map((url, index) => [index + 1, getWeightForUrl(input, url)]), + ); + return { + urls, + context: { + urlCount: urls.length, + weights, + }, + }; +} + +export function extractUrls(input: LighthouseUrls): string[] { + if (Array.isArray(input)) { + return input; + } + if (typeof input === 'string') { + return [input]; + } + return Object.keys(input); +} + +export function getWeightForUrl(input: LighthouseUrls, url: string): number { + if (typeof input === 'object' && !Array.isArray(input)) { + return input[url] ?? 1; + } + return 1; +} + +export function getUrlIdentifier(url: string): string { + try { + const { host, pathname } = new URL(url); + const path = pathname === '/' ? '' : pathname; + return `${host}${path}`; + } catch { + return url; + } +} + +export function expandAuditsForUrls(audits: Audit[], urls: string[]): Audit[] { + return urls.flatMap((url, index) => + audits.map(audit => ({ + ...audit, + slug: orderSlug(audit.slug, index), + title: `${audit.title} (${getUrlIdentifier(url)})`, + })), + ); +} + +export function expandGroupsForUrls(groups: Group[], urls: string[]): Group[] { + return urls.flatMap((url, index) => + groups.map(group => ({ + ...group, + slug: orderSlug(group.slug, index), + title: `${group.title} (${getUrlIdentifier(url)})`, + refs: group.refs.map(ref => ({ + ...ref, + slug: orderSlug(ref.slug, index), + })), + })), + ); +} + +export function expandOptionsForUrls( + options: FilterOptions, + urlCount: number, +): FilterOptions { + return Object.fromEntries( + Object.entries(options).map(([key, value]) => [ + key, + Array.isArray(value) + ? value.flatMap(slug => + Array.from({ length: urlCount }, (_, i) => orderSlug(slug, i)), + ) + : value, + ]), + ); +} + +export function processAuditsAndGroups(urls: string[], options: FilterOptions) { + if (!shouldExpandForUrls(urls.length)) { + return markSkippedAuditsAndGroups( + LIGHTHOUSE_NAVIGATION_AUDITS, + LIGHTHOUSE_GROUPS, + options, + ); + } + const expandedAudits = expandAuditsForUrls( + LIGHTHOUSE_NAVIGATION_AUDITS, + urls, + ); + const expandedGroups = expandGroupsForUrls(LIGHTHOUSE_GROUPS, urls); + const expandedOptions = expandOptionsForUrls(options, urls.length); + return markSkippedAuditsAndGroups( + expandedAudits, + expandedGroups, + expandedOptions, + ); +} diff --git a/packages/plugin-lighthouse/src/lib/processing.unit.test.ts b/packages/plugin-lighthouse/src/lib/processing.unit.test.ts new file mode 100644 index 000000000..5447a4faa --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/processing.unit.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, it } from 'vitest'; +import type { Audit, Group } from '@code-pushup/models'; +import { + expandAuditsForUrls, + expandGroupsForUrls, + expandOptionsForUrls, + extractUrls, + getUrlIdentifier, + getWeightForUrl, + normalizeUrlInput, + orderSlug, + processAuditsAndGroups, +} from './processing.js'; + +describe('orderSlug', () => { + it.each([ + [0, 'performance', 'performance-1'], + [1, 'performance', 'performance-2'], + [2, 'best-practices', 'best-practices-3'], + [1, 'cumulative-layout-shift', 'cumulative-layout-shift-2'], + ])('should append index %d + 1 to slug %j', (index, slug, expected) => { + expect(orderSlug(slug, index)).toBe(expected); + }); +}); + +describe('extractUrls', () => { + it.each([ + ['single string', 'https://a.com', ['https://a.com']], + [ + 'array', + ['https://a.com', 'https://b.com'], + ['https://a.com', 'https://b.com'], + ], + [ + 'object', + { 'https://a.com': 1, 'https://b.com': 2 }, + ['https://a.com', 'https://b.com'], + ], + ])('should extract URLs from %s', (_, input, expected) => { + expect(extractUrls(input)).toEqual(expected); + }); +}); + +describe('getUrlIdentifier', () => { + it.each([ + ['https://example.com', 'example.com'], + ['https://example.com/', 'example.com'], + ['http://example.com', 'example.com'], + ['https://example.com/about', 'example.com/about'], + ['https://example.com/about/', 'example.com/about/'], + ['https://example.com/docs/api', 'example.com/docs/api'], + ['https://example.com/page?q=test', 'example.com/page'], + ['https://example.com/page#section', 'example.com/page'], + ['https://example.com/page?q=test#section', 'example.com/page'], + ['https://example.com:3000', 'example.com:3000'], + ['https://example.com:3000/api', 'example.com:3000/api'], + ['https://www.example.com', 'www.example.com'], + ['https://api.example.com/v1', 'api.example.com/v1'], + ['not-a-url', 'not-a-url'], + ['just-text', 'just-text'], + ['', ''], + ['https://localhost', 'localhost'], + ['https://127.0.0.1:8080/test', '127.0.0.1:8080/test'], + ])('should convert %j to %j', (input, expected) => { + expect(getUrlIdentifier(input)).toBe(expected); + }); +}); + +describe('expandAuditsForUrls', () => { + const mockAudits: Audit[] = [ + { + slug: 'first-contentful-paint', + title: 'First Contentful Paint', + description: 'Measures FCP', + }, + { + slug: 'largest-contentful-paint', + title: 'Largest Contentful Paint', + description: 'Measures LCP', + }, + ]; + + it('should expand audits for multiple URLs', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const result = expandAuditsForUrls(mockAudits, urls); + + expect(result).toHaveLength(4); + expect(result.map(({ slug }) => slug)).toEqual([ + 'first-contentful-paint-1', + 'largest-contentful-paint-1', + 'first-contentful-paint-2', + 'largest-contentful-paint-2', + ]); + }); + + it('should update titles with URL identifiers', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const result = expandAuditsForUrls(mockAudits, urls); + + expect(result[0]?.title).toBe('First Contentful Paint (example.com)'); + expect(result[2]?.title).toBe('First Contentful Paint (example.com/about)'); + }); + + it('should preserve other audit properties', () => { + const auditWithExtra: Audit = { + slug: 'test-audit', + title: 'Test Audit', + description: 'Test description', + docsUrl: 'https://docs.example.com', + }; + + const result = expandAuditsForUrls( + [auditWithExtra], + ['https://example.com'], + ); + + expect(result[0]).toEqual({ + slug: 'test-audit-1', + title: 'Test Audit (example.com)', + description: 'Test description', + docsUrl: 'https://docs.example.com', + }); + }); + + it('should handle single URL', () => { + const result = expandAuditsForUrls(mockAudits, ['https://example.com']); + + expect(result).toHaveLength(2); + expect(result.map(a => a.slug)).toEqual([ + 'first-contentful-paint-1', + 'largest-contentful-paint-1', + ]); + }); + + it('should handle empty audits array', () => { + const result = expandAuditsForUrls([], ['https://example.com']); + expect(result).toHaveLength(0); + }); +}); + +describe('expandGroupsForUrls', () => { + const mockGroups: Group[] = [ + { + slug: 'performance', + title: 'Performance', + refs: [ + { slug: 'first-contentful-paint', weight: 1 }, + { slug: 'largest-contentful-paint', weight: 2 }, + ], + }, + { + slug: 'accessibility', + title: 'Accessibility', + refs: [{ slug: 'color-contrast', weight: 1 }], + }, + ]; + + it('should expand groups for multiple URLs', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const result = expandGroupsForUrls(mockGroups, urls); + + expect(result).toHaveLength(4); + expect(result.map(({ slug }) => slug)).toEqual([ + 'performance-1', + 'accessibility-1', + 'performance-2', + 'accessibility-2', + ]); + }); + + it('should update group titles with URL identifiers', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const result = expandGroupsForUrls(mockGroups, urls); + + expect(result[0]?.title).toBe('Performance (example.com)'); + expect(result[2]?.title).toBe('Performance (example.com/about)'); + }); + + it('should expand refs within groups', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const result = expandGroupsForUrls(mockGroups, urls); + + expect(result[0]?.refs).toEqual([ + { slug: 'first-contentful-paint-1', weight: 1 }, + { slug: 'largest-contentful-paint-1', weight: 2 }, + ]); + + expect(result[2]?.refs).toEqual([ + { slug: 'first-contentful-paint-2', weight: 1 }, + { slug: 'largest-contentful-paint-2', weight: 2 }, + ]); + }); + + it('should preserve other group properties', () => { + const groupWithExtra: Group = { + slug: 'test-group', + title: 'Test Group', + description: 'Test description', + refs: [{ slug: 'test-audit', weight: 1 }], + }; + + const result = expandGroupsForUrls( + [groupWithExtra], + ['https://example.com'], + ); + + expect(result[0]).toEqual({ + slug: 'test-group-1', + title: 'Test Group (example.com)', + description: 'Test description', + refs: [{ slug: 'test-audit-1', weight: 1 }], + }); + }); + + it('should handle empty groups array', () => { + const result = expandGroupsForUrls([], ['https://example.com']); + expect(result).toHaveLength(0); + }); +}); + +describe('expandOptionsForUrls', () => { + it('should expand onlyAudits options', () => { + const options = { + onlyAudits: ['first-contentful-paint', 'largest-contentful-paint'], + }; + const result = expandOptionsForUrls(options, 2); + + expect(result.onlyAudits).toEqual([ + 'first-contentful-paint-1', + 'first-contentful-paint-2', + 'largest-contentful-paint-1', + 'largest-contentful-paint-2', + ]); + }); + + it('should expand skipAudits options', () => { + const options = { skipAudits: ['performance-budget'] }; + const result = expandOptionsForUrls(options, 3); + + expect(result.skipAudits).toEqual([ + 'performance-budget-1', + 'performance-budget-2', + 'performance-budget-3', + ]); + }); + + it('should expand onlyCategories options', () => { + const options = { onlyCategories: ['performance', 'accessibility'] }; + const result = expandOptionsForUrls(options, 2); + + expect(result.onlyCategories).toEqual([ + 'performance-1', + 'performance-2', + 'accessibility-1', + 'accessibility-2', + ]); + }); + + it('should handle mixed filter options', () => { + const options = { + onlyAudits: ['first-contentful-paint'], + skipAudits: ['performance-budget'], + onlyCategories: ['performance'], + }; + const result = expandOptionsForUrls(options, 2); + + expect(result).toEqual({ + onlyAudits: ['first-contentful-paint-1', 'first-contentful-paint-2'], + skipAudits: ['performance-budget-1', 'performance-budget-2'], + onlyCategories: ['performance-1', 'performance-2'], + }); + }); + + it('should handle empty arrays', () => { + const options = { onlyAudits: [], skipAudits: [] }; + const result = expandOptionsForUrls(options, 2); + + expect(result).toEqual({ + onlyAudits: [], + skipAudits: [], + }); + }); + + it('should handle single URL count', () => { + const options = { onlyAudits: ['test-audit'] }; + const result = expandOptionsForUrls(options, 1); + + expect(result.onlyAudits).toEqual(['test-audit-1']); + }); +}); + +describe('processAuditsAndGroups', () => { + it('should return original audits and groups for single URL', () => { + const result = processAuditsAndGroups(['https://example.com'], {}); + + expect(result.audits).toBeDefined(); + expect(result.groups).toBeDefined(); + expect(result.audits.some(({ slug }) => slug.includes('-1'))).toBe(false); + expect(result.groups.some(({ slug }) => slug.includes('-1'))).toBe(false); + }); + + it('should expand audits and groups for multiple URLs', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const result = processAuditsAndGroups(urls, {}); + + expect(result.audits).toBeDefined(); + expect(result.groups).toBeDefined(); + + expect(result.audits.every(({ slug }) => /-[12]$/.test(slug))).toBe(true); + expect(result.groups.every(({ slug }) => /-[12]$/.test(slug))).toBe(true); + }); + + it('should apply filter options for multiple URLs', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const options = { onlyCategories: ['performance'] }; + const result = processAuditsAndGroups(urls, options); + + const performanceGroups = result.groups.filter(({ slug }) => + slug.startsWith('performance-'), + ); + const nonPerformanceGroups = result.groups.filter( + ({ slug }) => !slug.startsWith('performance-'), + ); + + expect(performanceGroups.map(g => g.slug)).toEqual([ + 'performance-1', + 'performance-2', + ]); + expect(performanceGroups.every(({ isSkipped }) => !isSkipped)).toBe(true); + expect(nonPerformanceGroups.every(({ isSkipped }) => isSkipped)).toBe(true); + }); + + it('should handle empty options', () => { + const urls = ['https://example.com', 'https://example.com/about']; + const result = processAuditsAndGroups(urls, {}); + + expect(result.audits.length).toBeGreaterThan(0); + expect(result.groups.length).toBeGreaterThan(0); + }); +}); + +describe('getWeightForUrl', () => { + it.each([ + [1, 'https://example.com', 'https://example.com'], + [ + 1, + ['https://example.com', 'https://example.com/about'], + 'https://example.com', + ], + [2, { 'https://example.com': 2 }, 'https://example.com'], + [0, { 'https://example.com/about': 0 }, 'https://example.com/about'], + [1, { 'https://example.com': 2 }, 'https://example.com/about'], + ])( + 'should return the weight of %d per input %j for URL %j', + (expected, input, url) => { + expect(getWeightForUrl(input, url)).toBe(expected); + }, + ); +}); + +describe('normalizeUrlInput', () => { + describe('string input', () => { + it('should normalize single URL string', () => { + expect(normalizeUrlInput('https://example.com')).toEqual({ + urls: ['https://example.com'], + context: { + urlCount: 1, + weights: { 1: 1 }, + }, + }); + }); + }); + + describe('array input', () => { + it('should normalize array of URLs', () => { + expect( + normalizeUrlInput(['https://example.com', 'https://example.com/about']), + ).toEqual({ + urls: ['https://example.com', 'https://example.com/about'], + context: { + urlCount: 2, + weights: { 1: 1, 2: 1 }, + }, + }); + }); + + it('should handle empty array', () => { + expect(normalizeUrlInput([])).toEqual({ + urls: [], + context: { + urlCount: 0, + weights: {}, + }, + }); + }); + + it('should handle single URL in array', () => { + expect(normalizeUrlInput(['https://example.com'])).toEqual({ + urls: ['https://example.com'], + context: { + urlCount: 1, + weights: { 1: 1 }, + }, + }); + }); + }); + + describe('WeightedUrl input', () => { + it('should normalize weighted URLs', () => { + expect( + normalizeUrlInput({ + 'https://example.com': 2, + 'https://example.com/about': 3, + 'https://example.com/contact': 1, + }), + ).toEqual({ + urls: [ + 'https://example.com', + 'https://example.com/about', + 'https://example.com/contact', + ], + context: { + urlCount: 3, + weights: { 1: 2, 2: 3, 3: 1 }, + }, + }); + }); + + it('should handle single weighted URL', () => { + expect(normalizeUrlInput({ 'https://example.com': 5 })).toEqual({ + urls: ['https://example.com'], + context: { + urlCount: 1, + weights: { 1: 5 }, + }, + }); + }); + + it('should preserve zero weights', () => { + expect( + normalizeUrlInput({ + 'https://example.com': 2, + 'https://example.com/about': 0, + }), + ).toEqual({ + urls: ['https://example.com', 'https://example.com/about'], + context: { + urlCount: 2, + weights: { 1: 2, 2: 0 }, + }, + }); + }); + + it('should handle empty WeightedUrl object', () => { + expect(normalizeUrlInput({})).toEqual({ + urls: [], + context: { + urlCount: 0, + weights: {}, + }, + }); + }); + }); + + describe('edge cases', () => { + it('should handle URLs with special characters', () => { + const result = normalizeUrlInput({ + 'https://example.com/path?query=test&foo=bar': 2, + 'https://example.com/path#section': 1, + }); + + expect(result.urls).toEqual([ + 'https://example.com/path?query=test&foo=bar', + 'https://example.com/path#section', + ]); + expect(result.context.weights).toEqual({ 1: 2, 2: 1 }); + }); + + it('should handle numeric weights including decimals', () => { + const result = normalizeUrlInput({ + 'https://example.com': 1.5, + 'https://example.com/about': 2.7, + }); + + expect(result.context.weights).toEqual({ 1: 1.5, 2: 2.7 }); + }); + }); +}); diff --git a/packages/plugin-lighthouse/src/lib/runner/runner.ts b/packages/plugin-lighthouse/src/lib/runner/runner.ts index a750dd992..92d2ed1c9 100644 --- a/packages/plugin-lighthouse/src/lib/runner/runner.ts +++ b/packages/plugin-lighthouse/src/lib/runner/runner.ts @@ -1,55 +1,82 @@ -import type { RunnerResult } from 'lighthouse'; +import type { Config, RunnerResult } from 'lighthouse'; import { runLighthouse } from 'lighthouse/cli/run.js'; import path from 'node:path'; import type { AuditOutputs, RunnerFunction } from '@code-pushup/models'; -import { ensureDirectoryExists } from '@code-pushup/utils'; +import { ensureDirectoryExists, ui } from '@code-pushup/utils'; +import { orderSlug, shouldExpandForUrls } from '../processing.js'; +import type { LighthouseOptions } from '../types.js'; import { DEFAULT_CLI_FLAGS } from './constants.js'; import type { LighthouseCliFlags } from './types.js'; import { - determineAndSetLogLevel, + enrichFlags, getConfig, normalizeAuditOutputs, toAuditOutputs, } from './utils.js'; export function createRunnerFunction( - urlUnderTest: string, + urls: string[], flags: LighthouseCliFlags = DEFAULT_CLI_FLAGS, ): RunnerFunction { return async (): Promise => { - const { - configPath, - preset, - outputPath, - ...parsedFlags - }: Partial = flags; - - const logLevel = determineAndSetLogLevel(parsedFlags); - - const config = await getConfig({ configPath, preset }); - if (outputPath) { - await ensureDirectoryExists(path.dirname(outputPath)); - } + const config = await getConfig(flags); + const normalizationFlags = enrichFlags(flags); + const isSingleUrl = !shouldExpandForUrls(urls.length); - const enrichedFlags = { - ...parsedFlags, - logLevel, - outputPath, - }; + const allResults = await urls.reduce(async (prev, url, index) => { + const acc = await prev; + try { + const enrichedFlags = isSingleUrl + ? normalizationFlags + : enrichFlags(flags, index + 1); - const runnerResult: unknown = await runLighthouse( - urlUnderTest, - enrichedFlags, - config, - ); + const auditOutputs = await runLighthouseForUrl( + url, + enrichedFlags, + config, + ); - if (runnerResult == null) { - throw new Error('Lighthouse did not produce a result.'); - } + const processedOutputs = isSingleUrl + ? auditOutputs + : auditOutputs.map(audit => ({ + ...audit, + slug: orderSlug(audit.slug, index), + })); - const { lhr } = runnerResult as RunnerResult; - const auditOutputs = toAuditOutputs(Object.values(lhr.audits), flags); + return [...acc, ...processedOutputs]; + } catch (error) { + ui().logger.warning((error as Error).message); + return acc; + } + }, Promise.resolve([])); - return normalizeAuditOutputs(auditOutputs, enrichedFlags); + if (allResults.length === 0) { + throw new Error( + isSingleUrl + ? 'Lighthouse did not produce a result.' + : 'Lighthouse failed to produce results for all URLs.', + ); + } + return normalizeAuditOutputs(allResults, normalizationFlags); }; } + +async function runLighthouseForUrl( + url: string, + flags: LighthouseOptions, + config: Config | undefined, +): Promise { + if (flags.outputPath) { + await ensureDirectoryExists(path.dirname(flags.outputPath)); + } + + const runnerResult: unknown = await runLighthouse(url, flags, config); + + if (runnerResult == null) { + throw new Error(`Lighthouse did not produce a result for URL: ${url}`); + } + + const { lhr } = runnerResult as RunnerResult; + + return toAuditOutputs(Object.values(lhr.audits), flags); +} diff --git a/packages/plugin-lighthouse/src/lib/runner/runner.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/runner.unit.test.ts index c0d2474bf..7774df24f 100644 --- a/packages/plugin-lighthouse/src/lib/runner/runner.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/runner.unit.test.ts @@ -2,25 +2,25 @@ import type { Config } from 'lighthouse'; import { runLighthouse } from 'lighthouse/cli/run.js'; import type { Result } from 'lighthouse/types/lhr/audit-result'; import { expect, vi } from 'vitest'; +import { ui } from '@code-pushup/utils'; import { DEFAULT_CLI_FLAGS } from './constants.js'; import { createRunnerFunction } from './runner.js'; import type { LighthouseCliFlags } from './types.js'; -import { determineAndSetLogLevel, getConfig } from './utils.js'; +import { enrichFlags, getConfig } from './utils.js'; // used for createRunnerMocking vi.mock('./utils', async () => { // Import the actual 'lighthouse' module const actual = await vi.importActual('./utils'); - const actualDetermineAndSetLogLevel = actual['determineAndSetLogLevel'] as ( - s: string, + const actualEnrichFlags = actual['enrichFlags'] as ( + f: LighthouseCliFlags, + i?: number, ) => string; // Return the mocked module, merging the actual module with overridden parts return { ...actual, - determineAndSetLogLevel: vi - .fn() - .mockImplementation(actualDetermineAndSetLogLevel), + enrichFlags: vi.fn().mockImplementation(actualEnrichFlags), getBudgets: vi.fn().mockImplementation((path: string) => [{ path }]), getConfig: vi.fn(), }; @@ -63,7 +63,7 @@ vi.mock('lighthouse/cli/run.js', async () => { describe('createRunnerFunction', () => { it('should call runLighthouse with defaults when executed with only url given', async () => { - const runner = createRunnerFunction('https://localhost:8080'); + const runner = createRunnerFunction(['https://localhost:8080']); await expect(runner(undefined)).resolves.toEqual( expect.arrayContaining([ { @@ -82,18 +82,24 @@ describe('createRunnerFunction', () => { ); }); - it('should call determineAndSetLogLevel with given verbose and quiet flags', async () => { - await createRunnerFunction('https://localhost:8080', { - verbose: true, - quiet: true, - } as LighthouseCliFlags)(undefined); - expect(determineAndSetLogLevel).toHaveBeenCalledWith( - expect.objectContaining({ verbose: true, quiet: true }), - ); + it('should call enrichFlags with correct parameters for single URL', async () => { + await createRunnerFunction(['https://localhost:8080'])(undefined); + + expect(enrichFlags).toHaveBeenCalledWith(DEFAULT_CLI_FLAGS); + }); + + it('should call enrichFlags with URL index for multiple URLs', async () => { + await createRunnerFunction([ + 'https://localhost:8080', + 'https://localhost:8081', + ])(undefined); + + expect(enrichFlags).toHaveBeenCalledWith(DEFAULT_CLI_FLAGS, 1); + expect(enrichFlags).toHaveBeenCalledWith(DEFAULT_CLI_FLAGS, 2); }); it('should call getConfig with given configPath', async () => { - await createRunnerFunction('https://localhost:8080', { + await createRunnerFunction(['https://localhost:8080'], { configPath: 'lh-config.js', } as LighthouseCliFlags)(undefined); expect(getConfig).toHaveBeenCalledWith( @@ -102,9 +108,130 @@ describe('createRunnerFunction', () => { }); it('should throw if lighthouse returns an empty result', async () => { - const runner = createRunnerFunction('fail'); + const runner = createRunnerFunction(['fail']); await expect(runner(undefined)).rejects.toThrow( 'Lighthouse did not produce a result.', ); }); + + it('should handle multiple URLs and add URL index to audit slugs', async () => { + const runner = createRunnerFunction([ + 'https://localhost:8080', + 'https://localhost:8081', + ]); + await expect(runner(undefined)).resolves.toEqual( + expect.arrayContaining([ + { + slug: 'cumulative-layout-shift-1', + value: 1200, + displayValue: '1.2 s', + score: 0.9, + }, + { + slug: 'cumulative-layout-shift-2', + value: 1200, + displayValue: '1.2 s', + score: 0.9, + }, + ]), + ); + expect(runLighthouse).toHaveBeenCalledWith( + 'https://localhost:8080', + expect.objectContaining({ + outputPath: expect.pathToMatch( + '.code-pushup/lighthouse/lighthouse-report-1.json', + ), + }), + undefined, + ); + expect(runLighthouse).toHaveBeenCalledWith( + 'https://localhost:8081', + expect.objectContaining({ + outputPath: expect.pathToMatch( + '.code-pushup/lighthouse/lighthouse-report-2.json', + ), + }), + undefined, + ); + expect(runLighthouse).toHaveBeenCalledTimes(2); + }); + + it('should handle single URL without adding index to audit slugs', async () => { + const runner = createRunnerFunction(['https://localhost:8080']); + await expect(runner(undefined)).resolves.toEqual( + expect.arrayContaining([ + { + slug: 'cumulative-layout-shift', + value: 1200, + displayValue: '1.2 s', + score: 0.9, + }, + ]), + ); + }); + + it('should continue with other URLs when one fails in multiple URL scenario', async () => { + const runner = createRunnerFunction([ + 'https://localhost:8080', + 'fail', + 'https://localhost:8082', + ]); + + await expect(runner(undefined)).resolves.toEqual( + expect.arrayContaining([ + { + slug: 'cumulative-layout-shift-1', + value: 1200, + displayValue: '1.2 s', + score: 0.9, + }, + { + slug: 'cumulative-layout-shift-3', + value: 1200, + displayValue: '1.2 s', + score: 0.9, + }, + ]), + ); + + expect(ui()).toHaveLogged( + 'warn', + `Lighthouse did not produce a result for URL: fail`, + ); + }); + + it('should throw error if all URLs fail in multiple URL scenario', async () => { + const runner = createRunnerFunction(['fail1', 'fail2', 'fail3']); + await expect(runner(undefined)).rejects.toThrow( + 'Lighthouse failed to produce results for all URLs.', + ); + }); + + it('should generate URL-specific output paths for multiple URLs', async () => { + const runner = createRunnerFunction([ + 'https://localhost:8080', + 'https://localhost:8081', + ]); + + await runner(undefined); + + expect(runLighthouse).toHaveBeenCalledWith( + 'https://localhost:8080', + expect.objectContaining({ + outputPath: expect.pathToMatch( + '.code-pushup/lighthouse/lighthouse-report-1.json', + ), + }), + undefined, + ); + expect(runLighthouse).toHaveBeenCalledWith( + 'https://localhost:8081', + expect.objectContaining({ + outputPath: expect.pathToMatch( + '.code-pushup/lighthouse/lighthouse-report-2.json', + ), + }), + undefined, + ); + }); }); diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.ts b/packages/plugin-lighthouse/src/lib/runner/utils.ts index 99bb3e40b..30ed5f51f 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.ts @@ -147,3 +147,23 @@ export async function getConfig( } return undefined; } + +export function enrichFlags( + flags: LighthouseCliFlags, + urlIndex?: number, +): LighthouseOptions { + const { outputPath, ...parsedFlags }: Partial = flags; + + const logLevel = determineAndSetLogLevel(parsedFlags); + + const urlSpecificOutputPath = + urlIndex && outputPath + ? outputPath.replace(/(\.[^.]+)?$/, `-${urlIndex}$1`) + : outputPath; + + return { + ...parsedFlags, + logLevel, + outputPath: urlSpecificOutputPath, + }; +} diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts index 2743e665e..8e7b400e4 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts @@ -13,9 +13,12 @@ import { } from '@code-pushup/models'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; +import { DEFAULT_CLI_FLAGS } from './constants.js'; import { unsupportedDetailTypes } from './details/details.js'; +import type { LighthouseCliFlags } from './types.js'; import { determineAndSetLogLevel, + enrichFlags, getConfig, normalizeAuditOutputs, toAuditOutputs, @@ -436,3 +439,66 @@ describe('determineAndSetLogLevel', () => { expect(debugLib.enabled('LH:*:verbose')).toBe(false); }); }); + +describe('enrichFlags', () => { + it('should return enriched flags without URL index for single URL', () => { + const flags = { + ...DEFAULT_CLI_FLAGS, + outputPath: '/path/to/report.json', + }; + expect(enrichFlags(flags).outputPath).toBe('/path/to/report.json'); + }); + + it('should add URL index to output path for multiple URLs', () => { + const flags = { + ...DEFAULT_CLI_FLAGS, + outputPath: '/path/to/report.json', + }; + expect(enrichFlags(flags, 2).outputPath).toBe('/path/to/report-2.json'); + }); + + it('should handle output path with multiple dots', () => { + const flags = { + ...DEFAULT_CLI_FLAGS, + outputPath: '/path/to/report.min.json', + }; + expect(enrichFlags(flags, 1).outputPath).toBe('/path/to/report.min-1.json'); + }); + + it('should handle default output path', () => { + expect(enrichFlags(DEFAULT_CLI_FLAGS).outputPath).toBe( + DEFAULT_CLI_FLAGS.outputPath, + ); + }); + + it('should not modify output path when URL index is 0 or undefined', () => { + const flags = { + ...DEFAULT_CLI_FLAGS, + outputPath: '/path/to/report.json', + }; + expect(enrichFlags(flags, 0).outputPath).toBe('/path/to/report.json'); + expect(enrichFlags(flags, undefined).outputPath).toBe( + '/path/to/report.json', + ); + }); + + it('should preserve all other flags', () => { + const flags: LighthouseCliFlags = { + outputPath: '/path/to/report.json', + chromeFlags: ['--headless'], + onlyAudits: ['performance'], + skipAudits: ['seo'], + onlyCategories: [], + preset: 'desktop', + }; + expect(enrichFlags(flags, 1)).toEqual({ + chromeFlags: ['--headless'], + onlyAudits: ['performance'], + skipAudits: ['seo'], + onlyCategories: [], + preset: 'desktop', + logLevel: 'info', + outputPath: '/path/to/report-1.json', + }); + }); +}); diff --git a/packages/plugin-lighthouse/src/lib/types.ts b/packages/plugin-lighthouse/src/lib/types.ts index 30820c1cb..2933287ab 100644 --- a/packages/plugin-lighthouse/src/lib/types.ts +++ b/packages/plugin-lighthouse/src/lib/types.ts @@ -1,5 +1,6 @@ import type { CliFlags } from 'lighthouse'; import type { ExcludeNullableProps } from '@code-pushup/utils'; +import type { LIGHTHOUSE_GROUP_SLUGS } from './constants.js'; export type LighthouseOptions = ExcludeNullableProps< Partial< @@ -26,3 +27,14 @@ export type LighthouseOptions = ExcludeNullableProps< onlyAudits?: string | string[]; skipAudits?: string | string[]; }; + +export type LighthouseGroupSlug = (typeof LIGHTHOUSE_GROUP_SLUGS)[number]; + +export type WeightedUrl = Record; + +export type LighthouseUrls = string | string[] | WeightedUrl; + +export type LighthouseContext = { + urlCount: number; + weights: Record; +}; diff --git a/packages/plugin-lighthouse/src/lib/utils.ts b/packages/plugin-lighthouse/src/lib/utils.ts index 6c1469548..af3b6b256 100644 --- a/packages/plugin-lighthouse/src/lib/utils.ts +++ b/packages/plugin-lighthouse/src/lib/utils.ts @@ -1,17 +1,11 @@ import type { Audit, CategoryRef, Group } from '@code-pushup/models'; import { toArray } from '@code-pushup/utils'; -import { LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; +import { LIGHTHOUSE_GROUP_SLUGS, LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; import type { LighthouseCliFlags } from './runner/types.js'; - -export type LighthouseGroupSlugs = - | 'performance' - | 'accessibility' - | 'best-practices' - | 'seo' - | 'pwa'; +import type { LighthouseGroupSlug } from './types.js'; export function lighthouseGroupRef( - groupSlug: LighthouseGroupSlugs, + groupSlug: LighthouseGroupSlug, weight = 1, ): CategoryRef { return { @@ -139,3 +133,12 @@ export function markSkippedAuditsAndGroups( groups: fullyMarkedGroups, }; } + +export function isLighthouseGroupSlug( + group: unknown, +): group is LighthouseGroupSlug { + return ( + typeof group === 'string' && + LIGHTHOUSE_GROUP_SLUGS.includes(group as LighthouseGroupSlug) + ); +} diff --git a/packages/plugin-lighthouse/vitest.unit.config.ts b/packages/plugin-lighthouse/vitest.unit.config.ts index 6b797a223..6739ccd4c 100644 --- a/packages/plugin-lighthouse/vitest.unit.config.ts +++ b/packages/plugin-lighthouse/vitest.unit.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', + '../../testing/test-setup/src/lib/extend/path.matcher.ts', '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', ], },