diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 2fd3da482..4805e2f68 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -1,11 +1,7 @@ /* eslint-disable @nx/enforce-module-boundaries */ import { createProjectGraphAsync } from '@nx/devkit'; -import type { - CategoryConfig, - CoreConfig, - PluginUrls, -} from './packages/models/src/index.js'; -import axePlugin, { axeCategories } from './packages/plugin-axe/src/index.js'; +import type { CoreConfig, PluginUrls } from './packages/models/src/index.js'; +import axePlugin, { axeGroupRefs } from './packages/plugin-axe/src/index.js'; import coveragePlugin, { type CoveragePluginConfig, getNxCoveragePaths, @@ -16,8 +12,7 @@ import eslintPlugin, { import jsPackagesPlugin from './packages/plugin-js-packages/src/index.js'; import jsDocsPlugin from './packages/plugin-jsdocs/src/index.js'; import { - lighthouseCategories, - lighthouseGroupRef, + lighthouseGroupRefs, lighthousePlugin, } from './packages/plugin-lighthouse/src/index.js'; import typescriptPlugin, { @@ -195,31 +190,30 @@ export async function configureLighthousePlugin( urls: PluginUrls, ): Promise { const lhPlugin = await lighthousePlugin(urls); - const lhCategories: CategoryConfig[] = [ - { - slug: 'performance', - title: 'Performance', - refs: [lighthouseGroupRef('performance')], - }, - { - slug: 'a11y', - title: 'Accessibility', - refs: [lighthouseGroupRef('accessibility')], - }, - { - slug: 'best-practices', - title: 'Best Practices', - refs: [lighthouseGroupRef('best-practices')], - }, - { - slug: 'seo', - title: 'SEO', - refs: [lighthouseGroupRef('seo')], - }, - ]; return { plugins: [lhPlugin], - categories: lighthouseCategories(lhPlugin, lhCategories), + categories: [ + { + slug: 'performance', + title: 'Performance', + refs: lighthouseGroupRefs(lhPlugin, 'performance'), + }, + { + slug: 'a11y', + title: 'Accessibility', + refs: lighthouseGroupRefs(lhPlugin, 'accessibility'), + }, + { + slug: 'best-practices', + title: 'Best Practices', + refs: lighthouseGroupRefs(lhPlugin, 'best-practices'), + }, + { + slug: 'seo', + title: 'SEO', + refs: lighthouseGroupRefs(lhPlugin, 'seo'), + }, + ], }; } @@ -227,6 +221,12 @@ export function configureAxePlugin(urls: PluginUrls): CoreConfig { const axe = axePlugin(urls); return { plugins: [axe], - categories: axeCategories(axe), + categories: [ + { + slug: 'axe-a11y', + title: 'Axe Accessibility', + refs: axeGroupRefs(axe), + }, + ], }; } diff --git a/packages/models/docs/models-reference.md b/packages/models/docs/models-reference.md index 8fcc6536b..23655c255 100644 --- a/packages/models/docs/models-reference.md +++ b/packages/models/docs/models-reference.md @@ -25,15 +25,15 @@ _All properties are optional._ _Object containing the following properties:_ -| Property | Description | Type | -| :----------------------- | :----------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | -| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | -| **`scores`** (\*) | Score comparison | _Object with properties:_ | -| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | -| **`values`** (\*) | Audit `value` comparison | _Object with properties:_ | -| **`displayValues`** (\*) | Audit `displayValue` comparison | _Object with properties:_ | +| Property | Description | Type | +| :----------------------- | :----------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | +| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | +| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | +| **`scores`** (\*) | Score comparison | _Object with properties:_ | +| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | +| **`values`** (\*) | Audit `value` comparison | _Object with properties:_ | +| **`displayValues`** (\*) | Audit `displayValue` comparison | _Object with properties:_ | _(\*) Required._ @@ -43,14 +43,14 @@ Audit information _Object containing the following properties:_ -| Property | Description | Type | -| :--------------- | :--------------------------------------- | :---------------------------- | -| **`slug`** (\*) | Reference to audit | [Slug](#slug) | -| `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` | -| **`value`** (\*) | Raw numeric value | `number` (_≥0_) | -| **`score`** (\*) | Value between 0 and 1 | [Score](#score) | -| `scoreTarget` | Pass/fail score threshold (0-1) | `number` (_≥0, ≤1_) | -| `details` | Detailed information | [AuditDetails](#auditdetails) | +| Property | Description | Type | +| :--------------- | :--------------------------------------- | :-------------------------------------- | +| **`slug`** (\*) | Reference to audit | [Slug](#slug) | +| `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` | +| **`value`** (\*) | Raw numeric value | [NonnegativeNumber](#nonnegativenumber) | +| **`score`** (\*) | Value between 0 and 1 | [Score](#score) | +| `scoreTarget` | Pass/fail score threshold (0-1) | `number` (_≥0, ≤1_) | +| `details` | Detailed information | [AuditDetails](#auditdetails) | _(\*) Required._ @@ -72,7 +72,7 @@ _Object containing the following properties:_ | `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_) | +| **`value`** (\*) | Raw numeric value | [NonnegativeNumber](#nonnegativenumber) | | **`score`** (\*) | Value between 0 and 1 | [Score](#score) | | `scoreTarget` | Pass/fail score threshold (0-1) | `number` (_≥0, ≤1_) | | `details` | Detailed information | [AuditDetails](#auditdetails) | @@ -90,7 +90,7 @@ _Object containing the following properties:_ | `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | | **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | | **`score`** (\*) | Value between 0 and 1 | [Score](#score) | -| **`value`** (\*) | Raw numeric value | `number` (_≥0_) | +| **`value`** (\*) | Raw numeric value | [NonnegativeNumber](#nonnegativenumber) | | `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` | _(\*) Required._ @@ -198,12 +198,12 @@ _(\*) Required._ _Object containing the following properties:_ -| Property | Description | Type | -| :---------------- | :----------------------------------------------------------------- | :------------------- | -| **`slug`** (\*) | Slug of an audit or group (depending on `type`) | [Slug](#slug) | -| **`weight`** (\*) | Weight used to calculate score | `number` (_≥0_) | -| **`type`** (\*) | Discriminant for reference kind, affects where `slug` is looked up | `'audit' \| 'group'` | -| **`plugin`** (\*) | Plugin slug (plugin should contain referenced audit or group) | [Slug](#slug) | +| Property | Description | Type | +| :---------------- | :----------------------------------------------------------------- | :-------------------------------------- | +| **`slug`** (\*) | Slug of an audit or group (depending on `type`) | [Slug](#slug) | +| **`weight`** (\*) | Weight used to calculate score | [NonnegativeNumber](#nonnegativenumber) | +| **`type`** (\*) | Discriminant for reference kind, affects where `slug` is looked up | `'audit' \| 'group'` | +| **`plugin`** (\*) | Plugin slug (plugin should contain referenced audit or group) | [Slug](#slug) | _(\*) Required._ @@ -332,10 +332,10 @@ Weighted reference to a group _Object containing the following properties:_ -| Property | Description | Type | -| :---------------- | :-------------------------------------------------------------- | :-------------- | -| **`slug`** (\*) | Reference slug to a group within this plugin (e.g. 'max-lines') | [Slug](#slug) | -| **`weight`** (\*) | Weight used to calculate score | `number` (_≥0_) | +| Property | Description | Type | +| :---------------- | :-------------------------------------------------------------- | :-------------------------------------- | +| **`slug`** (\*) | Reference slug to a group within this plugin (e.g. 'max-lines') | [Slug](#slug) | +| **`weight`** (\*) | Weight used to calculate score | [NonnegativeNumber](#nonnegativenumber) | _(\*) Required._ @@ -1262,6 +1262,10 @@ _Enum, one of the following possible values:_ +## NonnegativeNumber + +_Number which is greater than or equal to 0._ + ## PersistConfig _Object containing the following properties:_ @@ -1374,7 +1378,7 @@ _Union of the following possible types:_ - `string` (_url_) - `Array` -- _Object with dynamic keys of type_ `string` (_url_) _and values of type_ `number` (_≥0_) +- _Object with dynamic keys of type_ `string` (_url_) _and values of type_ [NonnegativeNumber](#nonnegativenumber) ## PositiveInt @@ -1574,3 +1578,9 @@ _Object containing the following properties:_ | `timeout` | Request timeout in minutes (default is 5) | `number` (_>0, int_) | _(\*) Required._ + +## Weight + +Coefficient for the given score (use weight 0 if only for display) + +_Number which is greater than or equal to 0._ diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index c1845b5dd..1f73af5f8 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -66,9 +66,11 @@ export { filePathSchema, globPathSchema, materialIconSchema, + nonnegativeNumberSchema, positiveIntSchema, scoreSchema, slugSchema, + weightSchema, type MaterialIcon, } from './lib/implementation/schemas.js'; export { exists } from './lib/implementation/utils.js'; diff --git a/packages/plugin-axe/README.md b/packages/plugin-axe/README.md index ccb6800fe..4be52fd40 100644 --- a/packages/plugin-axe/README.md +++ b/packages/plugin-axe/README.md @@ -163,51 +163,105 @@ Use `npx code-pushup print-config --onlyPlugins=axe` to list all audits and grou The plugin provides helpers to integrate Axe results into your categories. -### Auto-generate accessibility category +### Building categories with ref helpers -Use `axeCategories` to automatically create an accessibility category from all plugin groups: +Use `axeGroupRefs` and `axeAuditRefs` to build categories. These helpers automatically handle multi-URL expansion: ```ts -import axePlugin, { axeCategories } from '@code-pushup/axe-plugin'; +import axePlugin, { axeGroupRefs } from '@code-pushup/axe-plugin'; const axe = axePlugin('https://example.com'); export default { plugins: [axe], - categories: axeCategories(axe), + categories: [ + { + slug: 'a11y', + title: 'Accessibility', + refs: axeGroupRefs(axe), + }, + ], }; ``` -This configuration works with both single-URL and multi-URL configurations. For multi-URL setups, refs are automatically expanded for each URL with appropriate weights. +For multi-URL setups, refs are automatically expanded for each URL with appropriate weights: -### Custom categories +```ts +import axePlugin, { axeGroupRefs } from '@code-pushup/axe-plugin'; -For fine-grained control, provide your own categories with specific groups: +const axe = axePlugin({ + 'https://example.com': 2, + 'https://example.com/about': 1, +}); + +export default { + plugins: [axe], + categories: [ + { + slug: 'a11y', + title: 'Accessibility', + refs: axeGroupRefs(axe), + }, + ], +}; +``` + +### Custom categories with specific groups + +For fine-grained control, specify which groups to include: ```ts -import axePlugin, { axeCategories, axeGroupRef } from '@code-pushup/axe-plugin'; +import axePlugin, { axeGroupRefs } from '@code-pushup/axe-plugin'; -const axe = axePlugin(['https://example.com', 'https://example.com/about']); +const axe = axePlugin('https://example.com'); export default { plugins: [axe], - categories: axeCategories(axe, [ + categories: [ + { + slug: 'a11y', + title: 'Accessibility', + refs: [...axeGroupRefs(axe, 'aria', 2), ...axeGroupRefs(axe, 'color'), ...axeGroupRefs(axe, 'keyboard')], + }, + ], +}; +``` + +### Combining with Lighthouse + +For comprehensive accessibility testing, combine Axe with Lighthouse's accessibility group in a single category: + +```ts +import axePlugin, { axeGroupRefs } from '@code-pushup/axe-plugin'; +import lighthousePlugin, { lighthouseGroupRefs } from '@code-pushup/lighthouse-plugin'; + +const urls = ['https://example.com', 'https://example.com/about']; +const axe = axePlugin(urls); +const lighthouse = await lighthousePlugin(urls); + +export default { + plugins: [axe, lighthouse], + categories: [ { - slug: 'axe-a11y', - title: 'Axe Accessibility', - refs: [axeGroupRef('aria', 2), axeGroupRef('color'), axeGroupRef('keyboard')], + slug: 'a11y', + title: 'Accessibility', + refs: [...lighthouseGroupRefs(lighthouse, 'accessibility'), ...axeGroupRefs(axe)], }, - ]), + ], }; ``` +This gives you both Lighthouse's performance-focused accessibility audits and Axe's comprehensive WCAG coverage in one category score. + ### Helper functions -| Function | Description | -| --------------- | -------------------------------------- | -| `axeCategories` | Auto-generates or expands categories | -| `axeGroupRef` | Creates a category ref to an Axe group | -| `axeAuditRef` | Creates a category ref to an Axe audit | +| Function | Description | +| -------------- | -------------------------------------------------------- | +| `axeGroupRefs` | Creates category refs to Axe group(s), handles multi-URL | +| `axeAuditRefs` | Creates category refs to Axe audit(s), handles multi-URL | + +> [!TIP] +> Weights determine each ref's influence on the category score. Use weight `0` to include a ref as info only, without affecting the score. ### Type safety @@ -219,6 +273,16 @@ import type { AxeGroupSlug } from '@code-pushup/axe-plugin'; const group: AxeGroupSlug = 'aria'; ``` +### Deprecated helpers + +The following helpers are deprecated and will be removed in a future version: + +| Function | Replacement | +| --------------- | ------------------------------------------------- | +| `axeCategories` | Build categories manually with `axeGroupRefs` | +| `axeGroupRef` | Use `axeGroupRefs` (plural) for multi-URL support | +| `axeAuditRef` | Use `axeAuditRefs` (plural) for multi-URL support | + ## Resources - **[Axe-core rules](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)** - Complete list of accessibility rules diff --git a/packages/plugin-axe/src/index.ts b/packages/plugin-axe/src/index.ts index 87c707c6b..fc6a6c5b6 100644 --- a/packages/plugin-axe/src/index.ts +++ b/packages/plugin-axe/src/index.ts @@ -5,5 +5,10 @@ export default axePlugin; export type { AxePluginOptions, AxePreset } from './lib/config.js'; export type { AxeGroupSlug } from './lib/groups.js'; -export { axeAuditRef, axeGroupRef } from './lib/utils.js'; +export { + axeAuditRef, + axeAuditRefs, + axeGroupRef, + axeGroupRefs, +} from './lib/utils.js'; export { axeCategories } from './lib/categories.js'; diff --git a/packages/plugin-axe/src/lib/categories.ts b/packages/plugin-axe/src/lib/categories.ts index 1150693ec..98204145c 100644 --- a/packages/plugin-axe/src/lib/categories.ts +++ b/packages/plugin-axe/src/lib/categories.ts @@ -1,29 +1,32 @@ -import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models'; +import { + type CategoryConfig, + type PluginConfig, + validate, +} from '@code-pushup/models'; import { type PluginUrlContext, - createCategoryRefs, expandCategoryRefs, - removeIndex, + pluginUrlContextSchema, shouldExpandForUrls, - validateUrlContext, } from '@code-pushup/utils'; import { AXE_PLUGIN_SLUG } from './constants.js'; -import { type AxeCategoryGroupSlug, isAxeGroupSlug } from './groups.js'; +import { axeGroupRefs } from './utils.js'; /** - * Creates categories for the Axe plugin. - * - * @public - * @param plugin - {@link PluginConfig} object with groups and context - * @param categories - {@link CategoryConfig} optional user-defined categories - * @returns {CategoryConfig[]} - expanded and aggregated categories + * @deprecated Use `axeGroupRefs` to build categories manually instead. * * @example - * const axe = await axePlugin(urls); - * const axeCoreConfig = { - * plugins: [axe], - * categories: axeCategories(axe), - * }; + * // Instead of: + * const categories = axeCategories(axePlugin); + * + * // Use: + * const categories = [ + * { + * slug: 'a11y', + * title: 'Accessibility', + * refs: axeGroupRefs(axePlugin), + * }, + * ]; */ export function axeCategories( plugin: Pick, @@ -32,24 +35,29 @@ export function axeCategories( if (!plugin.groups || plugin.groups.length === 0) { return categories ?? []; } - validateUrlContext(plugin.context); if (!categories) { - return createCategories(plugin.groups, plugin.context); + return createCategories(plugin); } - return expandCategories(categories, plugin.context); + return expandCategories(plugin, categories); } function createCategories( - groups: Group[], - context: PluginUrlContext, + plugin: Pick, ): CategoryConfig[] { - return [createAggregatedCategory(groups, context)]; + return [ + { + slug: 'axe-a11y', + title: 'Axe Accessibility', + refs: axeGroupRefs(plugin), + }, + ]; } function expandCategories( + plugin: Pick, categories: CategoryConfig[], - context: PluginUrlContext, ): CategoryConfig[] { + const context = validate(pluginUrlContextSchema, plugin.context); if (!shouldExpandForUrls(context.urlCount)) { return categories; } @@ -58,20 +66,6 @@ function expandCategories( ); } -export function createAggregatedCategory( - groups: Group[], - context: PluginUrlContext, -): CategoryConfig { - const refs = extractGroupSlugs(groups).flatMap(slug => - createCategoryRefs(slug, AXE_PLUGIN_SLUG, context), - ); - return { - slug: 'axe-a11y', - title: 'Axe Accessibility', - refs, - }; -} - export function expandAggregatedCategory( category: CategoryConfig, context: PluginUrlContext, @@ -83,8 +77,3 @@ export function expandAggregatedCategory( ), }; } - -export function extractGroupSlugs(groups: Group[]): AxeCategoryGroupSlug[] { - const slugs = groups.map(({ slug }) => removeIndex(slug)); - return [...new Set(slugs)].filter(isAxeGroupSlug); -} diff --git a/packages/plugin-axe/src/lib/categories.unit.test.ts b/packages/plugin-axe/src/lib/categories.unit.test.ts index 2ae7155d8..8fe5ea0a4 100644 --- a/packages/plugin-axe/src/lib/categories.unit.test.ts +++ b/packages/plugin-axe/src/lib/categories.unit.test.ts @@ -1,6 +1,7 @@ +import ansis from 'ansis'; import { describe, expect, it } from 'vitest'; import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; -import { axeCategories, extractGroupSlugs } from './categories.js'; +import { axeCategories } from './categories.js'; import { AXE_PLUGIN_SLUG } from './constants.js'; import { axeGroupRef } from './utils.js'; @@ -112,33 +113,12 @@ describe('axeCategories', () => { }); it('should throw for invalid context', () => { - const plugin = createMockPlugin({ - context: { urlCount: 2, weights: { 1: 1 } }, - }); - - expect(() => axeCategories(plugin)).toThrow( - 'Invalid plugin context: weights count must match urlCount', - ); - }); -}); - -describe('extractGroupSlugs', () => { - it('should extract unique base slugs from groups', () => { - expect( - extractGroupSlugs([ - { slug: 'aria-1', title: 'ARIA 1', refs: [] }, - { slug: 'aria-2', title: 'ARIA 2', refs: [] }, - { slug: 'color', title: 'Color & Contrast', refs: [] }, - ]), - ).toEqual(['aria', 'color']); - }); - - it('should filter out invalid group slugs', () => { - expect( - extractGroupSlugs([ - { slug: 'aria', title: 'ARIA', refs: [] }, - { slug: 'invalid-group', title: 'Invalid', refs: [] }, - ]), - ).toEqual(['aria']); + expect(() => + axeCategories( + createMockPlugin({ + context: { urlCount: 2, weights: { 1: 1 } }, + }), + ), + ).toThrow(`Invalid ${ansis.bold('PluginUrlContext')}`); }); }); diff --git a/packages/plugin-axe/src/lib/groups.ts b/packages/plugin-axe/src/lib/groups.ts index ccbe59dab..dfd4cd2b0 100644 --- a/packages/plugin-axe/src/lib/groups.ts +++ b/packages/plugin-axe/src/lib/groups.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { axePresetSchema } from './config.js'; -/* WCAG presets for rule loading */ +/** WCAG presets for rule loading */ const axeWcagTags = [ 'wcag2a', 'wcag21a', @@ -31,7 +31,7 @@ export function getWcagPresetTags(preset: AxeWcagPreset): AxeWcagTag[] { return WCAG_PRESET_TAGS[preset]; } -/* Category groups for all presets */ +/** Category groups for all presets */ const axeCategoryGroupSlugs = [ 'aria', 'color', @@ -74,6 +74,6 @@ export function isAxeGroupSlug(slug: unknown): slug is AxeCategoryGroupSlug { return axeCategoryGroupSlugSchema.safeParse(slug).success; } -/* Combined exports */ +// Combined exports export const axeGroupSlugSchema = axeCategoryGroupSlugSchema; export type AxeGroupSlug = AxeCategoryGroupSlug; diff --git a/packages/plugin-axe/src/lib/utils.ts b/packages/plugin-axe/src/lib/utils.ts index 15639fe13..1e44910e6 100644 --- a/packages/plugin-axe/src/lib/utils.ts +++ b/packages/plugin-axe/src/lib/utils.ts @@ -1,7 +1,19 @@ -import type { CategoryRef } from '@code-pushup/models'; +import { + type CategoryRef, + type PluginConfig, + validate, +} from '@code-pushup/models'; +import { + expandCategoryRefs, + extractGroupSlugs, + pluginUrlContextSchema, +} from '@code-pushup/utils'; import { AXE_PLUGIN_SLUG } from './constants.js'; -import type { AxeGroupSlug } from './groups.js'; +import { type AxeGroupSlug, isAxeGroupSlug } from './groups.js'; +/** + * @deprecated Use `axeGroupRefs` instead for multi-URL support. + */ export function axeGroupRef(groupSlug: AxeGroupSlug, weight = 1): CategoryRef { return { plugin: AXE_PLUGIN_SLUG, @@ -11,6 +23,9 @@ export function axeGroupRef(groupSlug: AxeGroupSlug, weight = 1): CategoryRef { }; } +/** + * @deprecated Use `axeAuditRefs` instead for multi-URL support. + */ export function axeAuditRef(auditSlug: string, weight = 1): CategoryRef { return { plugin: AXE_PLUGIN_SLUG, @@ -19,3 +34,76 @@ export function axeAuditRef(auditSlug: string, weight = 1): CategoryRef { weight, }; } + +/** + * Creates category refs for Axe groups with multi-URL support. + * + * @param plugin - Axe plugin instance + * @param groupSlug - Optional group slug; if omitted, includes all groups + * @param groupWeight - Optional weight for the ref(s) + * @returns Array of category refs, expanded for each URL in multi-URL configs + */ +export function axeGroupRefs( + plugin: Pick, + groupSlug?: AxeGroupSlug, + groupWeight?: number, +): CategoryRef[] { + const context = validate(pluginUrlContextSchema, plugin.context); + if (groupSlug) { + return expandCategoryRefs( + { + slug: groupSlug, + weight: groupWeight, + type: 'group', + plugin: AXE_PLUGIN_SLUG, + }, + context, + ); + } + return axeGroupSlugs(plugin).flatMap(slug => + expandCategoryRefs( + { slug, type: 'group', plugin: AXE_PLUGIN_SLUG }, + context, + ), + ); +} + +/** + * Creates category refs for Axe audits with multi-URL support. + * + * @param plugin - Axe plugin instance + * @param auditSlug - Optional audit slug; if omitted, includes all audits + * @param auditWeight - Optional weight for the ref(s) + * @returns Array of category refs, expanded for each URL in multi-URL configs + */ +export function axeAuditRefs( + plugin: Pick, + auditSlug?: string, + auditWeight?: number, +): CategoryRef[] { + const context = validate(pluginUrlContextSchema, plugin.context); + if (auditSlug) { + return expandCategoryRefs( + { + slug: auditSlug, + weight: auditWeight, + type: 'audit', + plugin: AXE_PLUGIN_SLUG, + }, + context, + ); + } + return plugin.audits.flatMap(({ slug }) => + expandCategoryRefs( + { slug, type: 'audit', plugin: AXE_PLUGIN_SLUG }, + context, + ), + ); +} + +function axeGroupSlugs(plugin: Pick): AxeGroupSlug[] { + if (!plugin.groups) { + return []; + } + return extractGroupSlugs(plugin.groups).filter(isAxeGroupSlug); +} diff --git a/packages/plugin-axe/src/lib/utils.unit.test.ts b/packages/plugin-axe/src/lib/utils.unit.test.ts index b9cb726bf..41794d6b2 100644 --- a/packages/plugin-axe/src/lib/utils.unit.test.ts +++ b/packages/plugin-axe/src/lib/utils.unit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { AXE_PLUGIN_SLUG } from './constants.js'; -import { axeAuditRef, axeGroupRef } from './utils.js'; +import { + axeAuditRef, + axeAuditRefs, + axeGroupRef, + axeGroupRefs, +} from './utils.js'; describe('axeGroupRef', () => { it('should create a group reference with default weight', () => { @@ -41,3 +46,97 @@ describe('axeAuditRef', () => { }); }); }); + +describe('axeGroupRefs', () => { + it('should return refs for all groups when no slug provided', () => { + expect( + axeGroupRefs({ + groups: [ + { slug: 'aria-1', title: 'ARIA (url1)', refs: [] }, + { slug: 'aria-2', title: 'ARIA (url2)', refs: [] }, + ], + context: { urlCount: 2, weights: { 1: 2, 2: 3 } }, + }), + ).toStrictEqual([ + { plugin: AXE_PLUGIN_SLUG, slug: 'aria-1', type: 'group', weight: 2 }, + { plugin: AXE_PLUGIN_SLUG, slug: 'aria-2', type: 'group', weight: 3 }, + ]); + }); + + it('should return refs for specific group when slug provided', () => { + expect( + axeGroupRefs( + { + groups: [ + { slug: 'aria-1', title: 'ARIA (url1)', refs: [] }, + { slug: 'aria-2', title: 'ARIA (url2)', refs: [] }, + ], + context: { urlCount: 2, weights: { 1: 1, 2: 1 } }, + }, + 'aria', + 3, + ), + ).toStrictEqual([ + { plugin: AXE_PLUGIN_SLUG, slug: 'aria-1', type: 'group', weight: 2 }, + { plugin: AXE_PLUGIN_SLUG, slug: 'aria-2', type: 'group', weight: 2 }, + ]); + }); + + it('should return empty array when plugin has no groups', () => { + expect( + axeGroupRefs({ + groups: undefined, + context: { urlCount: 1, weights: { 1: 1 } }, + }), + ).toBeEmpty(); + }); +}); + +describe('axeAuditRefs', () => { + it('should return refs for specific audit with multi-URL expansion', () => { + expect( + axeAuditRefs( + { + audits: [ + { slug: 'label-1', title: 'Form elements must have labels (url1)' }, + { slug: 'label-2', title: 'Form elements must have labels (url2)' }, + { slug: 'aria-roles-1', title: '`[role]` values are valid (url1)' }, + { slug: 'aria-roles-2', title: '`[role]` values are valid (url2)' }, + ], + context: { urlCount: 2, weights: { 1: 1, 2: 2 } }, + }, + 'aria-roles', + 3, + ), + ).toStrictEqual([ + { + plugin: AXE_PLUGIN_SLUG, + slug: 'aria-roles-1', + type: 'audit', + weight: 2, + }, + { + plugin: AXE_PLUGIN_SLUG, + slug: 'aria-roles-2', + type: 'audit', + weight: 2.5, + }, + ]); + }); + + it('should return refs for all audits when no slug provided', () => { + expect( + axeAuditRefs({ + audits: [{ slug: 'duplicate-id-aria', title: 'ARIA IDs are unique' }], + context: { urlCount: 1, weights: { 1: 1 } }, + }), + ).toStrictEqual([ + { + plugin: AXE_PLUGIN_SLUG, + slug: 'duplicate-id-aria', + type: 'audit', + weight: 1, + }, + ]); + }); +}); diff --git a/packages/plugin-lighthouse/README.md b/packages/plugin-lighthouse/README.md index 2e469db1c..0033d2a23 100644 --- a/packages/plugin-lighthouse/README.md +++ b/packages/plugin-lighthouse/README.md @@ -211,65 +211,92 @@ For a complete guide on Lighthouse configuration read the [official documentatio The plugin provides helpers to integrate Lighthouse results into your categories. -### Auto-generate categories +### Building categories with ref helpers -Use `lighthouseCategories` to automatically create categories from all plugin groups: +Use `lighthouseGroupRefs` and `lighthouseAuditRefs` to build categories. These helpers automatically handle multi-URL expansion: ```ts -import lighthousePlugin, { lighthouseCategories } from '@code-pushup/lighthouse-plugin'; +import lighthousePlugin, { lighthouseGroupRefs } from '@code-pushup/lighthouse-plugin'; const lighthouse = await lighthousePlugin('https://example.com'); export default { plugins: [lighthouse], - categories: lighthouseCategories(lighthouse), + categories: [ + { + slug: 'performance', + title: 'Performance', + refs: lighthouseGroupRefs(lighthouse, 'performance'), + }, + { + slug: 'seo', + title: 'SEO', + refs: lighthouseGroupRefs(lighthouse, 'seo'), + }, + ], }; ``` -The helper creates categories for all four Lighthouse groups: `performance`, `accessibility`, `best-practices`, and `seo`. For multi-URL setups, refs are automatically expanded for each URL with appropriate weights. - -### Custom categories - -For fine-grained control, provide your own categories. You can reference groups (Lighthouse's native categories) or individual audits: +For multi-URL setups, refs are automatically expanded for each URL with appropriate weights: ```ts -import lighthousePlugin, { lighthouseAuditRef, lighthouseCategories, lighthouseGroupRef } from '@code-pushup/lighthouse-plugin'; +import lighthousePlugin, { lighthouseAuditRefs, lighthouseGroupRefs } from '@code-pushup/lighthouse-plugin'; -const lighthouse = await lighthousePlugin(['https://example.com', 'https://example.com/about']); +const lighthouse = await lighthousePlugin({ + 'https://example.com': 2, + 'https://example.com/about': 1, +}); export default { plugins: [lighthouse], - categories: lighthouseCategories(lighthouse, [ + categories: [ { slug: 'performance', title: 'Performance', - refs: [lighthouseGroupRef('performance')], + refs: lighthouseGroupRefs(lighthouse, 'performance'), }, { slug: 'core-web-vitals', title: 'Core Web Vitals', - refs: [lighthouseAuditRef('largest-contentful-paint', 3), lighthouseAuditRef('cumulative-layout-shift', 2), lighthouseAuditRef('first-contentful-paint', 1)], + refs: [...lighthouseAuditRefs(lighthouse, 'largest-contentful-paint', 3), ...lighthouseAuditRefs(lighthouse, 'cumulative-layout-shift', 2), ...lighthouseAuditRefs(lighthouse, 'first-contentful-paint', 1)], }, - ]), + ], }; ``` -> [!NOTE] -> Referencing individual audits offers more granularity but increases maintenance costs. Use `npx code-pushup print-config --onlyPlugins=lighthouse` to list all available audits and groups. +### Get all groups -> [!TIP] -> Weights determine each ref's influence on the category score. Use weight `0` to include a ref as info only, without affecting the score. +Call `lighthouseGroupRefs` without a slug to get refs for all Lighthouse groups: -> [!TIP] -> You can use `lighthouseGroupRef` and `lighthouseAuditRef` directly in your categories without the helper. However, wrapping them in `lighthouseCategories` future-proofs your config for multi-URL setups. +```ts +import lighthousePlugin, { lighthouseGroupRefs } from '@code-pushup/lighthouse-plugin'; + +const lighthouse = await lighthousePlugin('https://example.com'); + +export default { + plugins: [lighthouse], + categories: [ + { + slug: 'lighthouse', + title: 'Lighthouse', + refs: lighthouseGroupRefs(lighthouse), // all groups + }, + ], +}; +``` ### Helper functions -| Function | Description | -| ---------------------- | -------------------------------------------- | -| `lighthouseCategories` | Auto-generates or expands categories | -| `lighthouseGroupRef` | Creates a category ref to a Lighthouse group | -| `lighthouseAuditRef` | Creates a category ref to a Lighthouse audit | +| Function | Description | +| --------------------- | --------------------------------------------------------------- | +| `lighthouseGroupRefs` | Creates category refs to Lighthouse group(s), handles multi-URL | +| `lighthouseAuditRefs` | Creates category refs to Lighthouse audit(s), handles multi-URL | + +> [!NOTE] +> Referencing individual audits offers more granularity but increases maintenance costs. Use `npx code-pushup print-config --onlyPlugins=lighthouse` to list all available audits and groups. + +> [!TIP] +> Weights determine each ref's influence on the category score. Use weight `0` to include a ref as info only, without affecting the score. ### Type safety @@ -281,4 +308,14 @@ import type { LighthouseGroupSlug } from '@code-pushup/lighthouse-plugin'; const group: LighthouseGroupSlug = 'performance'; ``` +### Deprecated helpers + +The following helpers are deprecated and will be removed in a future version: + +| Function | Replacement | +| ---------------------- | -------------------------------------------------------- | +| `lighthouseCategories` | Build categories manually with `lighthouseGroupRefs` | +| `lighthouseGroupRef` | Use `lighthouseGroupRefs` (plural) for multi-URL support | +| `lighthouseAuditRef` | Use `lighthouseAuditRefs` (plural) for multi-URL support | + If you want to contribute, please refer to [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/packages/plugin-lighthouse/src/index.ts b/packages/plugin-lighthouse/src/index.ts index 7803807db..4357d4fb5 100644 --- a/packages/plugin-lighthouse/src/index.ts +++ b/packages/plugin-lighthouse/src/index.ts @@ -6,7 +6,12 @@ export { LIGHTHOUSE_PLUGIN_SLUG, LIGHTHOUSE_OUTPUT_PATH, } from './lib/constants.js'; -export { lighthouseAuditRef, lighthouseGroupRef } from './lib/utils.js'; +export { + lighthouseAuditRef, + lighthouseAuditRefs, + lighthouseGroupRef, + lighthouseGroupRefs, +} from './lib/utils.js'; export type { LighthouseGroupSlug, LighthouseOptions } from './lib/types.js'; export { lighthousePlugin } from './lib/lighthouse-plugin.js'; export default lighthousePlugin; diff --git a/packages/plugin-lighthouse/src/lib/categories.ts b/packages/plugin-lighthouse/src/lib/categories.ts index fd4058014..e62d2d224 100644 --- a/packages/plugin-lighthouse/src/lib/categories.ts +++ b/packages/plugin-lighthouse/src/lib/categories.ts @@ -1,35 +1,34 @@ -import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models'; +import { + type CategoryConfig, + type PluginConfig, + validate, +} from '@code-pushup/models'; import { type PluginUrlContext, - createCategoryRefs, expandCategoryRefs, - removeIndex, + pluginUrlContextSchema, shouldExpandForUrls, - validateUrlContext, } from '@code-pushup/utils'; import { LIGHTHOUSE_GROUP_SLUGS, LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; import { LIGHTHOUSE_GROUPS } from './runner/constants.js'; import type { LighthouseGroupSlug } from './types.js'; -import { isLighthouseGroupSlug } from './utils.js'; +import { lighthouseGroupSlugs } 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 + * @deprecated Use `lighthouseGroupRefs` to build categories manually instead. * * @example - * const lhPlugin = await lighthousePlugin(urls); - * const lhCoreConfig = { - * plugins: [lhPlugin], - * categories: lighthouseCategories(lhPlugin), - * }; + * // Instead of: + * const categories = lighthouseCategories(lhPlugin); + * + * // Use: + * const categories = [ + * { + * slug: 'performance', + * title: 'Performance', + * refs: lighthouseGroupRefs(lhPlugin, 'performance'), + * }, + * ]; */ export function lighthouseCategories( plugin: Pick, @@ -38,11 +37,10 @@ export function lighthouseCategories( if (!plugin.groups || plugin.groups.length === 0) { return categories ?? []; } - validateUrlContext(plugin.context); if (!categories) { - return createCategories(plugin.groups, plugin.context); + return createCategories(plugin); } - return expandCategories(categories, plugin.context); + return expandCategories(plugin, categories); } /** @@ -52,18 +50,19 @@ export function lighthouseCategories( export const mergeLighthouseCategories = lighthouseCategories; function createCategories( - groups: Group[], - context: PluginUrlContext, + plugin: Pick, ): CategoryConfig[] { - return extractGroupSlugs(groups).map(slug => + const context = validate(pluginUrlContextSchema, plugin.context); + return lighthouseGroupSlugs(plugin).map(slug => createAggregatedCategory(slug, context), ); } function expandCategories( + plugin: Pick, categories: CategoryConfig[], - context: PluginUrlContext, ): CategoryConfig[] { + const context = validate(pluginUrlContextSchema, plugin.context); if (!shouldExpandForUrls(context.urlCount)) { return categories; } @@ -91,7 +90,10 @@ export function createAggregatedCategory( slug: group.slug, title: group.title, ...(group.description && { description: group.description }), - refs: createCategoryRefs(group.slug, LIGHTHOUSE_PLUGIN_SLUG, context), + refs: expandCategoryRefs( + { plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: group.slug, type: 'group' }, + context, + ), }; } @@ -112,12 +114,3 @@ export function expandAggregatedCategory( ), }; } - -/** - * 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 }) => removeIndex(slug)); - return [...new Set(slugs)].filter(isLighthouseGroupSlug); -} diff --git a/packages/plugin-lighthouse/src/lib/categories.unit.test.ts b/packages/plugin-lighthouse/src/lib/categories.unit.test.ts index 2d05db31d..5ef7f44cb 100644 --- a/packages/plugin-lighthouse/src/lib/categories.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/categories.unit.test.ts @@ -3,7 +3,6 @@ import type { CategoryConfig } from '@code-pushup/models'; import { createAggregatedCategory, expandAggregatedCategory, - extractGroupSlugs, lighthouseCategories, } from './categories.js'; import { LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; @@ -178,13 +177,13 @@ describe('lighthouseCategories', () => { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance-1', - weight: 1, + weight: 1.5, }, { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance-2', - weight: 1, + weight: 1.5, }, ], }); @@ -247,13 +246,13 @@ describe('lighthouseCategories', () => { type: 'audit', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'first-contentful-paint-1', - weight: 1, + weight: 1.5, }, { type: 'audit', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'first-contentful-paint-2', - weight: 1, + weight: 1.5, }, ]); }); @@ -491,48 +490,6 @@ describe('lighthouseCategories', () => { }); }); -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( @@ -667,7 +624,7 @@ describe('expandAggregatedCategory', () => { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance-1', - weight: 3, + weight: 2, }, { type: 'group', @@ -685,7 +642,7 @@ describe('expandAggregatedCategory', () => { type: 'audit', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'first-contentful-paint-2', - weight: 1, + weight: 2, }, ]); }); @@ -744,7 +701,7 @@ describe('expandAggregatedCategory', () => { ).toEqual(category); }); - it('should prioritize URL weights over user-defined category weights', () => { + it('should average user-defined and URL weights', () => { expect( expandAggregatedCategory( { @@ -766,18 +723,18 @@ describe('expandAggregatedCategory', () => { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance-1', - weight: 3, + weight: 2.5, }, { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance-2', - weight: 5, + weight: 3.5, }, ]); }); - it('should fall back to user-defined weight when URL weight is missing', () => { + it('should average weights for each URL independently', () => { expect( expandAggregatedCategory( { @@ -792,25 +749,25 @@ describe('expandAggregatedCategory', () => { }, ], }, - { urlCount: 2, weights: { 1: 3 } }, + { urlCount: 2, weights: { 1: 3, 2: 5 } }, ).refs, ).toEqual([ { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance-1', - weight: 3, + weight: 5, }, { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance-2', - weight: 7, + weight: 6, }, ]); }); - it('should not add suffixes for single URL but preserve weights', () => { + it('should not add suffixes for single URL but average weights', () => { expect( expandAggregatedCategory( { @@ -832,7 +789,7 @@ describe('expandAggregatedCategory', () => { type: 'group', plugin: LIGHTHOUSE_PLUGIN_SLUG, slug: 'performance', - weight: 5, + weight: 3, }, ]); }); diff --git a/packages/plugin-lighthouse/src/lib/utils.ts b/packages/plugin-lighthouse/src/lib/utils.ts index 4c1605ca2..0ec139dbb 100644 --- a/packages/plugin-lighthouse/src/lib/utils.ts +++ b/packages/plugin-lighthouse/src/lib/utils.ts @@ -1,9 +1,23 @@ -import type { Audit, CategoryRef, Group } from '@code-pushup/models'; -import { toArray } from '@code-pushup/utils'; +import { + type Audit, + type CategoryRef, + type Group, + type PluginConfig, + validate, +} from '@code-pushup/models'; +import { + expandCategoryRefs, + extractGroupSlugs, + pluginUrlContextSchema, + toArray, +} from '@code-pushup/utils'; import { LIGHTHOUSE_GROUP_SLUGS, LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; import type { LighthouseCliFlags } from './runner/types.js'; import type { LighthouseGroupSlug } from './types.js'; +/** + * @deprecated Use `lighthouseGroupRefs` instead for multi-URL support. + */ export function lighthouseGroupRef( groupSlug: LighthouseGroupSlug, weight = 1, @@ -16,6 +30,9 @@ export function lighthouseGroupRef( }; } +/** + * @deprecated Use `lighthouseAuditRefs` instead for multi-URL support. + */ export function lighthouseAuditRef(auditSlug: string, weight = 1): CategoryRef { return { plugin: LIGHTHOUSE_PLUGIN_SLUG, @@ -25,6 +42,81 @@ export function lighthouseAuditRef(auditSlug: string, weight = 1): CategoryRef { }; } +/** + * Creates category refs for Lighthouse groups with multi-URL support. + * + * @param plugin - Lighthouse plugin instance + * @param groupSlug - Optional group slug; if omitted, includes all groups + * @param groupWeight - Optional weight for the ref(s) + * @returns Array of category refs, expanded for each URL in multi-URL configs + */ +export function lighthouseGroupRefs( + plugin: Pick, + groupSlug?: LighthouseGroupSlug, + groupWeight?: number, +): CategoryRef[] { + const context = validate(pluginUrlContextSchema, plugin.context); + if (groupSlug) { + return expandCategoryRefs( + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: groupSlug, + type: 'group', + weight: groupWeight, + }, + context, + ); + } + return lighthouseGroupSlugs(plugin).flatMap(slug => + expandCategoryRefs( + { plugin: LIGHTHOUSE_PLUGIN_SLUG, slug, type: 'group' }, + context, + ), + ); +} + +/** + * Creates category refs for Lighthouse audits with multi-URL support. + * + * @param plugin - Lighthouse plugin instance + * @param auditSlug - Optional audit slug; if omitted, includes all audits + * @param auditWeight - Optional weight for the ref(s) + * @returns Array of category refs, expanded for each URL in multi-URL configs + */ +export function lighthouseAuditRefs( + plugin: Pick, + auditSlug?: string, + auditWeight?: number, +): CategoryRef[] { + const context = validate(pluginUrlContextSchema, plugin.context); + if (auditSlug) { + return expandCategoryRefs( + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: auditSlug, + type: 'audit', + weight: auditWeight, + }, + context, + ); + } + return plugin.audits.flatMap(({ slug }) => + expandCategoryRefs( + { plugin: LIGHTHOUSE_PLUGIN_SLUG, slug, type: 'audit' }, + context, + ), + ); +} + +export function lighthouseGroupSlugs( + plugin: Pick, +): LighthouseGroupSlug[] { + if (!plugin.groups) { + return []; + } + return extractGroupSlugs(plugin.groups).filter(isLighthouseGroupSlug); +} + class NotImplementedError extends Error { constructor(plural: string, slugs: string[]) { const formattedSlugs = slugs.map(slug => `"${slug}"`).join(', '); diff --git a/packages/plugin-lighthouse/src/lib/utils.unit.test.ts b/packages/plugin-lighthouse/src/lib/utils.unit.test.ts index 9a25db34f..ec5ff5619 100644 --- a/packages/plugin-lighthouse/src/lib/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/utils.unit.test.ts @@ -6,9 +6,12 @@ import { categoryRefSchema, pluginConfigSchema, } from '@code-pushup/models'; +import { LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; import { lighthouseAuditRef, + lighthouseAuditRefs, lighthouseGroupRef, + lighthouseGroupRefs, markSkippedAuditsAndGroups, validateAudits, validateOnlyCategories, @@ -44,6 +47,112 @@ describe('lighthouseGroupRef', () => { }); }); +describe('lighthouseGroupRefs', () => { + it('should return refs for all groups when no slug provided', () => { + expect( + lighthouseGroupRefs({ + groups: [ + { slug: 'performance-1', title: 'Performance (url1)', refs: [] }, + { slug: 'performance-2', title: 'Performance (url2)', refs: [] }, + ], + context: { urlCount: 2, weights: { 1: 2, 2: 3 } }, + }), + ).toStrictEqual([ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + type: 'group', + weight: 2, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + type: 'group', + weight: 3, + }, + ]); + }); + + it('should return refs for specific group when slug provided', () => { + expect( + lighthouseGroupRefs( + { + groups: [ + { slug: 'performance-1', title: 'Performance (url1)', refs: [] }, + { slug: 'performance-2', title: 'Performance (url2)', refs: [] }, + ], + context: { urlCount: 2, weights: { 1: 1, 2: 1 } }, + }, + 'performance', + 3, + ), + ).toStrictEqual([ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-1', + type: 'group', + weight: 2, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'performance-2', + type: 'group', + weight: 2, + }, + ]); + }); + + it('should return empty array when plugin has no groups', () => { + expect( + lighthouseGroupRefs({ + groups: undefined, + context: { urlCount: 1, weights: { 1: 1 } }, + }), + ).toBeEmpty(); + }); +}); + +describe('lighthouseAuditRefs', () => { + it('should return refs for specific audit with multi-URL expansion', () => { + expect( + lighthouseAuditRefs( + { audits: [], context: { urlCount: 2, weights: { 1: 1, 2: 2 } } }, + 'first-contentful-paint', + 3, + ), + ).toStrictEqual([ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint-1', + type: 'audit', + weight: 2, + }, + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint-2', + type: 'audit', + weight: 2.5, + }, + ]); + }); + + it('should return refs for all audits when no slug provided', () => { + expect( + lighthouseAuditRefs({ + audits: [{ slug: 'first-contentful-paint', title: '' }], + context: { urlCount: 1, weights: { 1: 1 } }, + }), + ).toStrictEqual([ + { + plugin: LIGHTHOUSE_PLUGIN_SLUG, + slug: 'first-contentful-paint', + type: 'audit', + weight: 1, + }, + ]); + }); +}); + describe('validateAudits', () => { it('should not throw for audit slugs existing in given audits', () => { expect( diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c2db42147..f019b8055 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -96,18 +96,16 @@ export { Logger, logger } from './lib/logger.js'; export { mergeConfigs } from './lib/merge-configs.js'; export { addIndex, - ContextValidationError, - createCategoryRefs, expandAuditsForUrls, expandCategoryRefs, expandGroupsForUrls, - removeIndex, + extractGroupSlugs, shouldExpandForUrls, - validateUrlContext, } from './lib/plugin-url-aggregation.js'; export { getUrlIdentifier, normalizeUrlInput, + pluginUrlContextSchema, type PluginUrlContext, } from './lib/plugin-url-config.js'; export { diff --git a/packages/utils/src/lib/plugin-url-aggregation.ts b/packages/utils/src/lib/plugin-url-aggregation.ts index 5fb75a0e4..bae5de4d2 100644 --- a/packages/utils/src/lib/plugin-url-aggregation.ts +++ b/packages/utils/src/lib/plugin-url-aggregation.ts @@ -1,9 +1,4 @@ -import type { - Audit, - CategoryRef, - Group, - PluginConfig, -} from '@code-pushup/models'; +import type { Audit, CategoryRef, Group } from '@code-pushup/models'; import { type PluginUrlContext, SINGLE_URL_THRESHOLD, @@ -22,12 +17,21 @@ export function removeIndex(slug: string): string { return slug.replace(/-\d+$/, ''); } +export function extractGroupSlugs(groups: Group[]): string[] { + const slugs = groups.map(({ slug }) => removeIndex(slug)); + return [...new Set(slugs)]; +} + export function resolveUrlWeight( weights: PluginUrlContext['weights'], index: number, userDefinedWeight?: number, ): number { - return weights[index + 1] ?? userDefinedWeight ?? 1; + const urlWeight = weights[index + 1] ?? 1; + if (userDefinedWeight == null) { + return urlWeight; + } + return (urlWeight + userDefinedWeight) / 2; } export function expandAuditsForUrls(audits: Audit[], urls: string[]): Audit[] { @@ -54,23 +58,10 @@ export function expandGroupsForUrls(groups: Group[], urls: string[]): Group[] { ); } -export function createCategoryRefs( - groupSlug: string, - pluginSlug: string, - context: PluginUrlContext, -): CategoryRef[] { - return Array.from({ length: context.urlCount }, (_, i) => ({ - plugin: pluginSlug, - slug: shouldExpandForUrls(context.urlCount) - ? addIndex(groupSlug, i) - : groupSlug, - type: 'group', - weight: resolveUrlWeight(context.weights, i), - })); -} +type CategoryRefInput = Omit & { weight?: number }; export function expandCategoryRefs( - ref: CategoryRef, + ref: CategoryRefInput, context: PluginUrlContext, ): CategoryRef[] { return Array.from({ length: context.urlCount }, (_, i) => ({ @@ -81,27 +72,3 @@ export function expandCategoryRefs( weight: resolveUrlWeight(context.weights, i, ref.weight), })); } - -export class ContextValidationError extends Error { - constructor(message: string) { - super(`Invalid plugin context: ${message}`); - } -} - -export function validateUrlContext( - context: PluginConfig['context'], -): asserts context is PluginUrlContext { - 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'); - } -} diff --git a/packages/utils/src/lib/plugin-url-aggregation.unit.test.ts b/packages/utils/src/lib/plugin-url-aggregation.unit.test.ts index 6cd71d87c..37462edf0 100644 --- a/packages/utils/src/lib/plugin-url-aggregation.unit.test.ts +++ b/packages/utils/src/lib/plugin-url-aggregation.unit.test.ts @@ -1,14 +1,12 @@ import { - ContextValidationError, addIndex, - createCategoryRefs, expandAuditsForUrls, expandCategoryRefs, expandGroupsForUrls, + extractGroupSlugs, removeIndex, resolveUrlWeight, shouldExpandForUrls, - validateUrlContext, } from './plugin-url-aggregation.js'; describe('shouldExpandForUrls', () => { @@ -47,23 +45,25 @@ describe('removeIndex', () => { }); describe('resolveUrlWeight', () => { - it('should return weight from context', () => { + it('should return URL weight when no user weight provided', () => { expect(resolveUrlWeight({ 1: 2, 2: 3 }, 0)).toBe(2); expect(resolveUrlWeight({ 1: 2, 2: 3 }, 1)).toBe(3); }); - it('should fallback to user-defined weight', () => { - expect(resolveUrlWeight({}, 0, 5)).toBe(5); - expect(resolveUrlWeight({ 1: 2 }, 1, 4)).toBe(4); - }); - - it('should fallback to 1 if no weight found', () => { + it('should fallback to 1 when no URL weight and no user weight', () => { expect(resolveUrlWeight({}, 0)).toBe(1); expect(resolveUrlWeight({ 1: 2 }, 1)).toBe(1); }); - it('should prioritize context over user-defined', () => { - expect(resolveUrlWeight({ 1: 3 }, 0, 5)).toBe(3); + it('should average URL and user weights when both provided', () => { + expect(resolveUrlWeight({ 1: 3 }, 0, 5)).toBe(4); + expect(resolveUrlWeight({ 1: 2, 2: 3 }, 0, 4)).toBe(3); + expect(resolveUrlWeight({ 1: 2, 2: 3 }, 1, 4)).toBe(3.5); + }); + + it('should average with fallback URL weight of 1 when URL weight missing', () => { + expect(resolveUrlWeight({}, 0, 5)).toBe(3); + expect(resolveUrlWeight({ 1: 2 }, 1, 4)).toBe(2.5); }); }); @@ -81,61 +81,78 @@ describe('expandAuditsForUrls', () => { }, ]; - 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 expand audits for multiple URLs with updated slugs and titles', () => { + expect( + expandAuditsForUrls(mockAudits, [ + 'https://example.com', + 'https://example.com/about', + ]), + ).toStrictEqual([ + { + slug: 'first-contentful-paint-1', + title: 'First Contentful Paint (example.com)', + description: 'Measures FCP', + }, + { + slug: 'largest-contentful-paint-1', + title: 'Largest Contentful Paint (example.com)', + description: 'Measures LCP', + }, + { + slug: 'first-contentful-paint-2', + title: 'First Contentful Paint (example.com/about)', + description: 'Measures FCP', + }, + { + slug: 'largest-contentful-paint-2', + title: 'Largest Contentful Paint (example.com/about)', + description: 'Measures LCP', + }, ]); }); - 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 = { - 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', - }); + expect( + expandAuditsForUrls( + [ + { + slug: 'test-audit', + title: 'Test Audit', + description: 'Test description', + docsUrl: 'https://docs.example.com', + }, + ], + ['https://example.com'], + ), + ).toStrictEqual([ + { + 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', + expect( + expandAuditsForUrls(mockAudits, ['https://example.com']), + ).toStrictEqual([ + { + slug: 'first-contentful-paint-1', + title: 'First Contentful Paint (example.com)', + description: 'Measures FCP', + }, + { + slug: 'largest-contentful-paint-1', + title: 'Largest Contentful Paint (example.com)', + description: 'Measures LCP', + }, ]); }); it('should handle empty audits array', () => { - const result = expandAuditsForUrls([], ['https://example.com']); - expect(result).toHaveLength(0); + expect(expandAuditsForUrls([], ['https://example.com'])).toBeEmpty(); }); }); @@ -156,205 +173,166 @@ describe('expandGroupsForUrls', () => { }, ]; - 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 = { - 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('createCategoryRefs', () => { - it('should create refs for multiple URLs with expansion', () => { + it('should expand groups for multiple URLs with updated slugs, titles and refs', () => { expect( - createCategoryRefs('performance', 'lighthouse', { - urlCount: 2, - weights: { 1: 2, 2: 3 }, - }), - ).toEqual([ - { plugin: 'lighthouse', slug: 'performance-1', type: 'group', weight: 2 }, - { plugin: 'lighthouse', slug: 'performance-2', type: 'group', weight: 3 }, + expandGroupsForUrls(mockGroups, [ + 'https://example.com', + 'https://example.com/about', + ]), + ).toStrictEqual([ + { + slug: 'performance-1', + title: 'Performance (example.com)', + refs: [ + { slug: 'first-contentful-paint-1', weight: 1 }, + { slug: 'largest-contentful-paint-1', weight: 2 }, + ], + }, + { + 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: 'largest-contentful-paint-2', weight: 2 }, + ], + }, + { + slug: 'accessibility-2', + title: 'Accessibility (example.com/about)', + refs: [{ slug: 'color-contrast-2', weight: 1 }], + }, ]); }); - it('should create refs for single URL without expansion', () => { + it('should preserve other group properties', () => { expect( - createCategoryRefs('performance', 'lighthouse', { - urlCount: 1, - weights: { 1: 1 }, - }), - ).toEqual([ - { plugin: 'lighthouse', slug: 'performance', type: 'group', weight: 1 }, + expandGroupsForUrls( + [ + { + slug: 'test-group', + title: 'Test Group', + description: 'Test description', + refs: [{ slug: 'test-audit', weight: 1 }], + }, + ], + ['https://example.com'], + ), + ).toStrictEqual([ + { + slug: 'test-group-1', + title: 'Test Group (example.com)', + description: 'Test description', + refs: [{ slug: 'test-audit-1', weight: 1 }], + }, ]); }); - it('should use default weight of 1 if not in context', () => { - const result = createCategoryRefs('performance', 'lighthouse', { - urlCount: 2, - weights: {}, - }); - - expect(result[0]?.weight).toBe(1); - expect(result[1]?.weight).toBe(1); + it('should handle empty groups array', () => { + expect(expandGroupsForUrls([], ['https://example.com'])).toBeEmpty(); }); }); describe('expandCategoryRefs', () => { - it('should expand ref for multiple URLs with slug ordering', () => { + it('should average URL and user weights for multiple URLs', () => { expect( expandCategoryRefs( - { - plugin: 'lighthouse', - slug: 'performance', - type: 'group', - weight: 1, - }, + { plugin: 'lighthouse', slug: 'performance', type: 'group', weight: 1 }, { urlCount: 2, weights: { 1: 2, 2: 3 } }, ), - ).toEqual([ - { plugin: 'lighthouse', slug: 'performance-1', type: 'group', weight: 2 }, - { plugin: 'lighthouse', slug: 'performance-2', type: 'group', weight: 3 }, + ).toStrictEqual([ + { + plugin: 'lighthouse', + slug: 'performance-1', + type: 'group', + weight: 1.5, + }, + { plugin: 'lighthouse', slug: 'performance-2', type: 'group', weight: 2 }, ]); }); - it('should not expand for single URL', () => { + it('should average URL and user weights for single URL', () => { expect( expandCategoryRefs( - { - plugin: 'lighthouse', - slug: 'performance', - type: 'group', - weight: 1, - }, + { plugin: 'lighthouse', slug: 'performance', type: 'group', weight: 1 }, { urlCount: 1, weights: { 1: 5 } }, ), - ).toEqual([ - { plugin: 'lighthouse', slug: 'performance', type: 'group', weight: 5 }, + ).toStrictEqual([ + { plugin: 'lighthouse', slug: 'performance', type: 'group', weight: 3 }, ]); }); - it('should preserve user-defined weight with fallback to context', () => { - const result = expandCategoryRefs( - { - plugin: 'lighthouse', - slug: 'performance', - type: 'group', - weight: 10, - }, - { urlCount: 2, weights: { 1: 2, 2: 3 } }, - ); - - expect(result[0]?.weight).toBe(2); - expect(result[1]?.weight).toBe(3); + it('should use URL weights when user-defined weight is undefined', () => { + expect( + expandCategoryRefs( + { plugin: 'lighthouse', slug: 'performance', type: 'group' }, + { urlCount: 2, weights: { 1: 2, 2: 3 } }, + ), + ).toStrictEqual([ + { plugin: 'lighthouse', slug: 'performance-1', type: 'group', weight: 2 }, + { plugin: 'lighthouse', slug: 'performance-2', type: 'group', weight: 3 }, + ]); }); it('should work with audit refs', () => { expect( expandCategoryRefs( - { - plugin: 'lighthouse', - slug: 'fcp', - type: 'audit', - weight: 1, - }, + { plugin: 'lighthouse', slug: 'fcp', type: 'audit', weight: 1 }, { urlCount: 2, weights: { 1: 1, 2: 1 } }, ), - ).toEqual([ + ).toStrictEqual([ { plugin: 'lighthouse', slug: 'fcp-1', type: 'audit', weight: 1 }, { plugin: 'lighthouse', slug: 'fcp-2', type: 'audit', weight: 1 }, ]); }); }); -describe('validateUrlContext', () => { - it('should throw error for invalid context (undefined)', () => { - expect(() => validateUrlContext(undefined)).toThrow( - new ContextValidationError('must be an object'), - ); - }); - - it('should throw error for invalid context (missing urlCount)', () => { - expect(() => validateUrlContext({ weights: {} })).toThrow( - new ContextValidationError('urlCount must be a non-negative number'), - ); +describe('extractGroupSlugs', () => { + it('should extract unique base slugs from ordered groups', () => { + expect( + extractGroupSlugs([ + { 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: [] }, + ]), + ).toEqual(['performance', 'accessibility']); }); - it('should throw error for invalid context (negative urlCount)', () => { - expect(() => validateUrlContext({ urlCount: -1, weights: {} })).toThrow( - new ContextValidationError('urlCount must be a non-negative number'), - ); + it('should handle non-ordered groups', () => { + expect( + extractGroupSlugs([ + { slug: 'performance', title: 'Performance', refs: [] }, + { slug: 'accessibility', title: 'Accessibility', refs: [] }, + ]), + ).toEqual(['performance', 'accessibility']); }); - it('should throw error for invalid context (missing weights)', () => { - expect(() => validateUrlContext({ urlCount: 2 })).toThrow( - new ContextValidationError('weights must be an object'), - ); + it('should handle mixed ordered and non-ordered groups', () => { + expect( + extractGroupSlugs([ + { slug: 'performance', title: 'Performance', refs: [] }, + { slug: 'accessibility-1', title: 'Accessibility 1', refs: [] }, + { slug: 'accessibility-2', title: 'Accessibility 2', refs: [] }, + ]), + ).toEqual(['performance', 'accessibility']); }); - it('should throw error for invalid context (mismatched weights count)', () => { - expect(() => - validateUrlContext({ urlCount: 2, weights: { 1: 1 } }), - ).toThrow(new ContextValidationError('weights count must match urlCount')); + it('should return unique slugs only', () => { + expect( + extractGroupSlugs([ + { slug: 'performance-1', title: 'Performance 1', refs: [] }, + { slug: 'performance-2', title: 'Performance 2', refs: [] }, + { slug: 'performance-3', title: 'Performance 3', refs: [] }, + ]), + ).toEqual(['performance']); }); - it('should accept valid context', () => { - expect(() => - validateUrlContext({ urlCount: 2, weights: { 1: 1, 2: 1 } }), - ).not.toThrow(); + it('should handle empty groups array', () => { + expect(extractGroupSlugs([])).toBeEmpty(); }); }); diff --git a/packages/utils/src/lib/plugin-url-config.ts b/packages/utils/src/lib/plugin-url-config.ts index d575e30f1..957354b8a 100644 --- a/packages/utils/src/lib/plugin-url-config.ts +++ b/packages/utils/src/lib/plugin-url-config.ts @@ -1,9 +1,21 @@ -import type { PluginUrls } from '@code-pushup/models'; +import { z } from 'zod'; +import { + type PluginUrls, + nonnegativeNumberSchema, + weightSchema, +} from '@code-pushup/models'; -export type PluginUrlContext = { - urlCount: number; - weights: Record; -}; +export const pluginUrlContextSchema = z + .object({ + urlCount: nonnegativeNumberSchema, + weights: z.record(z.string(), weightSchema), + }) + .refine(({ urlCount, weights }) => Object.keys(weights).length === urlCount, { + message: 'weights count must match urlCount', + }) + .meta({ title: 'PluginUrlContext' }); + +export type PluginUrlContext = z.infer; export const SINGLE_URL_THRESHOLD = 1; diff --git a/packages/utils/src/lib/plugin-url-config.unit.test.ts b/packages/utils/src/lib/plugin-url-config.unit.test.ts index c28374f6e..475e1f3e4 100644 --- a/packages/utils/src/lib/plugin-url-config.unit.test.ts +++ b/packages/utils/src/lib/plugin-url-config.unit.test.ts @@ -1,4 +1,8 @@ -import { getUrlIdentifier, normalizeUrlInput } from './plugin-url-config.js'; +import { + getUrlIdentifier, + normalizeUrlInput, + pluginUrlContextSchema, +} from './plugin-url-config.js'; describe('getUrlIdentifier', () => { it.each([ @@ -28,12 +32,9 @@ describe('getUrlIdentifier', () => { describe('normalizeUrlInput', () => { describe('string input', () => { it('should normalize single URL string', () => { - expect(normalizeUrlInput('https://example.com')).toEqual({ + expect(normalizeUrlInput('https://example.com')).toStrictEqual({ urls: ['https://example.com'], - context: { - urlCount: 1, - weights: { 1: 1 }, - }, + context: { urlCount: 1, weights: { 1: 1 } }, }); }); }); @@ -42,32 +43,23 @@ describe('normalizeUrlInput', () => { it('should normalize array of URLs', () => { expect( normalizeUrlInput(['https://example.com', 'https://example.com/about']), - ).toEqual({ + ).toStrictEqual({ urls: ['https://example.com', 'https://example.com/about'], - context: { - urlCount: 2, - weights: { 1: 1, 2: 1 }, - }, + context: { urlCount: 2, weights: { 1: 1, 2: 1 } }, }); }); it('should handle empty array', () => { - expect(normalizeUrlInput([])).toEqual({ + expect(normalizeUrlInput([])).toStrictEqual({ urls: [], - context: { - urlCount: 0, - weights: {}, - }, + context: { urlCount: 0, weights: {} }, }); }); it('should handle single URL in array', () => { - expect(normalizeUrlInput(['https://example.com'])).toEqual({ + expect(normalizeUrlInput(['https://example.com'])).toStrictEqual({ urls: ['https://example.com'], - context: { - urlCount: 1, - weights: { 1: 1 }, - }, + context: { urlCount: 1, weights: { 1: 1 } }, }); }); }); @@ -80,26 +72,20 @@ describe('normalizeUrlInput', () => { 'https://example.com/about': 3, 'https://example.com/contact': 1, }), - ).toEqual({ + ).toStrictEqual({ urls: [ 'https://example.com', 'https://example.com/about', 'https://example.com/contact', ], - context: { - urlCount: 3, - weights: { 1: 2, 2: 3, 3: 1 }, - }, + context: { urlCount: 3, weights: { 1: 2, 2: 3, 3: 1 } }, }); }); it('should handle single weighted URL', () => { - expect(normalizeUrlInput({ 'https://example.com': 5 })).toEqual({ + expect(normalizeUrlInput({ 'https://example.com': 5 })).toStrictEqual({ urls: ['https://example.com'], - context: { - urlCount: 1, - weights: { 1: 5 }, - }, + context: { urlCount: 1, weights: { 1: 5 } }, }); }); @@ -109,47 +95,61 @@ describe('normalizeUrlInput', () => { 'https://example.com': 2, 'https://example.com/about': 0, }), - ).toEqual({ + ).toStrictEqual({ urls: ['https://example.com', 'https://example.com/about'], - context: { - urlCount: 2, - weights: { 1: 2, 2: 0 }, - }, + context: { urlCount: 2, weights: { 1: 2, 2: 0 } }, }); }); it('should handle empty object', () => { - expect(normalizeUrlInput({})).toEqual({ + expect(normalizeUrlInput({})).toStrictEqual({ urls: [], - context: { - urlCount: 0, - weights: {}, - }, + 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( + normalizeUrlInput({ + 'https://example.com/path?query=test&foo=bar': 2, + 'https://example.com/path#section': 1, + }), + ).toStrictEqual({ + urls: [ + 'https://example.com/path?query=test&foo=bar', + 'https://example.com/path#section', + ], + context: { urlCount: 2, weights: { 1: 2, 2: 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 }); + expect( + normalizeUrlInput({ + 'https://example.com': 1.5, + 'https://example.com/about': 2.7, + }).context.weights, + ).toStrictEqual({ 1: 1.5, 2: 2.7 }); }); }); }); + +describe('pluginUrlContextSchema', () => { + it.each([ + [undefined, /expected object/i], + [{ weights: {} }, /expected number/i], + [{ urlCount: -1, weights: {} }, /too small/i], + [{ urlCount: 2 }, /expected record/i], + [{ urlCount: 2, weights: { 1: 1 } }, /weights count must match/i], + ])('should throw error for invalid context: %j', (pattern, expectedError) => { + expect(() => pluginUrlContextSchema.parse(pattern)).toThrow(expectedError); + }); + + it('should accept valid context', () => { + expect(() => + pluginUrlContextSchema.parse({ urlCount: 2, weights: { 1: 1, 2: 1 } }), + ).not.toThrow(); + }); +});