From d89741a12ebd94c80fb4b44bc20a2b885d78ff5f Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 4 Nov 2025 13:57:53 -0500 Subject: [PATCH 01/18] feat(plugin-axe): add plugin scaffolding --- packages/plugin-axe/eslint.config.js | 12 +++++ packages/plugin-axe/package.json | 57 +++++++++++++++++++++++ packages/plugin-axe/project.json | 13 ++++++ packages/plugin-axe/src/index.ts | 1 + packages/plugin-axe/src/lib/axe-plugin.ts | 3 ++ packages/plugin-axe/tsconfig.json | 24 ++++++++++ packages/plugin-axe/tsconfig.lib.json | 10 ++++ packages/plugin-axe/tsconfig.test.json | 14 ++++++ packages/plugin-axe/vitest.unit.config.ts | 29 ++++++++++++ tsconfig.base.json | 1 + 10 files changed, 164 insertions(+) create mode 100644 packages/plugin-axe/eslint.config.js create mode 100644 packages/plugin-axe/package.json create mode 100644 packages/plugin-axe/project.json create mode 100644 packages/plugin-axe/src/index.ts create mode 100644 packages/plugin-axe/src/lib/axe-plugin.ts create mode 100644 packages/plugin-axe/tsconfig.json create mode 100644 packages/plugin-axe/tsconfig.lib.json create mode 100644 packages/plugin-axe/tsconfig.test.json create mode 100644 packages/plugin-axe/vitest.unit.config.ts diff --git a/packages/plugin-axe/eslint.config.js b/packages/plugin-axe/eslint.config.js new file mode 100644 index 000000000..2656b27cb --- /dev/null +++ b/packages/plugin-axe/eslint.config.js @@ -0,0 +1,12 @@ +import tseslint from 'typescript-eslint'; +import baseConfig from '../../eslint.config.js'; + +export default tseslint.config(...baseConfig, { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, +}); diff --git a/packages/plugin-axe/package.json b/packages/plugin-axe/package.json new file mode 100644 index 000000000..d0591381d --- /dev/null +++ b/packages/plugin-axe/package.json @@ -0,0 +1,57 @@ +{ + "name": "@code-pushup/axe-plugin", + "version": "0.84.0", + "license": "MIT", + "description": "Code PushUp plugin for detecting accessibility issues using Axe 🌐", + "homepage": "https://github.com/code-pushup/cli/tree/main/packages/plugin-axe#readme", + "bugs": { + "url": "https://github.com/code-pushup/cli/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3A%22🧩%20axe-plugin%22" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/code-pushup/cli.git", + "directory": "packages/plugin-axe" + }, + "keywords": [ + "CLI", + "Code PushUp", + "plugin", + "axe", + "axe-core", + "accessibility", + "a11y", + "WCAG", + "compliance", + "testing", + "quality", + "automation", + "runtime analysis", + "audit", + "score monitoring", + "developer tools", + "conformance", + "KPI tracking", + "tech debt", + "automated feedback", + "regression guard", + "actionable feedback" + ], + "publishConfig": { + "access": "public" + }, + "type": "module", + "dependencies": { + "@axe-core/playwright": "^4.11.0", + "@code-pushup/models": "0.84.0", + "@code-pushup/utils": "0.84.0", + "axe-core": "^4.11.0", + "zod": "^4.1.12" + }, + "peerDependencies": { + "playwright-core": "^1.56.1" + }, + "files": [ + "src", + "!**/*.tsbuildinfo" + ] +} diff --git a/packages/plugin-axe/project.json b/packages/plugin-axe/project.json new file mode 100644 index 000000000..34238ce83 --- /dev/null +++ b/packages/plugin-axe/project.json @@ -0,0 +1,13 @@ +{ + "name": "plugin-axe", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugin-axe/src", + "projectType": "library", + "tags": ["scope:plugin", "type:feature", "publishable"], + "// targets": "to see all targets run: nx show project plugin-axe --web", + "targets": { + "build": {}, + "lint": {}, + "unit-test": {} + } +} diff --git a/packages/plugin-axe/src/index.ts b/packages/plugin-axe/src/index.ts new file mode 100644 index 000000000..8f365ef00 --- /dev/null +++ b/packages/plugin-axe/src/index.ts @@ -0,0 +1 @@ +export * from './lib/axe-plugin.js'; diff --git a/packages/plugin-axe/src/lib/axe-plugin.ts b/packages/plugin-axe/src/lib/axe-plugin.ts new file mode 100644 index 000000000..452bdc85d --- /dev/null +++ b/packages/plugin-axe/src/lib/axe-plugin.ts @@ -0,0 +1,3 @@ +export function axePlugin(): string { + return 'plugin-axe'; +} diff --git a/packages/plugin-axe/tsconfig.json b/packages/plugin-axe/tsconfig.json new file mode 100644 index 000000000..b1c387a74 --- /dev/null +++ b/packages/plugin-axe/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "types": ["vitest"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/packages/plugin-axe/tsconfig.lib.json b/packages/plugin-axe/tsconfig.lib.json new file mode 100644 index 000000000..a1279fadd --- /dev/null +++ b/packages/plugin-axe/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["vitest.unit.config.ts", "src/**/*.test.ts"] +} diff --git a/packages/plugin-axe/tsconfig.test.json b/packages/plugin-axe/tsconfig.test.json new file mode 100644 index 000000000..5bbe011ce --- /dev/null +++ b/packages/plugin-axe/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": ["vitest.unit.config.ts", "src/**/*.test.ts"] +} diff --git a/packages/plugin-axe/vitest.unit.config.ts b/packages/plugin-axe/vitest.unit.config.ts new file mode 100644 index 000000000..b21131759 --- /dev/null +++ b/packages/plugin-axe/vitest.unit.config.ts @@ -0,0 +1,29 @@ +/// +import { defineConfig } from 'vitest/config'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-axe', + test: { + reporters: ['basic'], + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: '../../coverage/plugin-axe/unit-tests', + exclude: ['mocks/**', '**/types.ts'], + }, + environment: 'node', + include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + globalSetup: ['../../global-setup.ts'], + setupFiles: [ + '../../testing/test-setup/src/lib/console.mock.ts', + '../../testing/test-setup/src/lib/reset.mocks.ts', + ], + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 4bca1c69f..ac98b47ed 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,6 +20,7 @@ "allowSyntheticDefaultImports": true, "verbatimModuleSyntax": true, "paths": { + "@code-pushup/axe-plugin": ["packages/plugin-axe/src/index.ts"], "@code-pushup/ci": ["packages/ci/src/index.ts"], "@code-pushup/cli": ["packages/cli/src/index.ts"], "@code-pushup/core": ["packages/core/src/index.ts"], From 44bc1afc840cb4c0488ca3f7135b90ae2ea06046 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 4 Nov 2025 19:50:19 -0500 Subject: [PATCH 02/18] feat(plugin-axe): add metadata transformations --- packages/plugin-axe/src/lib/constants.ts | 12 ++ .../src/lib/meta/audits.unit.test.ts | 54 +++++++ .../src/lib/meta/groups.unit.test.ts | 128 +++++++++++++++ packages/plugin-axe/src/lib/meta/transform.ts | 146 ++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 packages/plugin-axe/src/lib/constants.ts create mode 100644 packages/plugin-axe/src/lib/meta/audits.unit.test.ts create mode 100644 packages/plugin-axe/src/lib/meta/groups.unit.test.ts create mode 100644 packages/plugin-axe/src/lib/meta/transform.ts diff --git a/packages/plugin-axe/src/lib/constants.ts b/packages/plugin-axe/src/lib/constants.ts new file mode 100644 index 000000000..ede6aaab9 --- /dev/null +++ b/packages/plugin-axe/src/lib/constants.ts @@ -0,0 +1,12 @@ +export const AXE_PLUGIN_SLUG = 'axe'; + +export const AXE_PRESETS = [ + 'wcag21aa', + 'wcag22aa', + 'best-practice', + 'all', +] as const; + +export type AxePreset = (typeof AXE_PRESETS)[number]; + +export const AXE_DEFAULT_PRESET: AxePreset = 'wcag21aa'; diff --git a/packages/plugin-axe/src/lib/meta/audits.unit.test.ts b/packages/plugin-axe/src/lib/meta/audits.unit.test.ts new file mode 100644 index 000000000..065e4c955 --- /dev/null +++ b/packages/plugin-axe/src/lib/meta/audits.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import type { Audit } from '@code-pushup/models'; +import { loadAxeRules, transformRulesToAudits } from './transform.js'; + +describe('transformRulesToAudits', () => { + describe('wcag21aa preset', () => { + it('should return approximately 67 audits', () => { + const audits = transformRulesToAudits(loadAxeRules('wcag21aa')); + + expect(audits.length).toBeGreaterThanOrEqual(65); + expect(audits.length).toBeLessThanOrEqual(70); + }); + }); + + describe('wcag22aa preset', () => { + it('should return approximately 68 audits', () => { + const audits = transformRulesToAudits(loadAxeRules('wcag22aa')); + + expect(audits.length).toBeGreaterThanOrEqual(66); + expect(audits.length).toBeLessThanOrEqual(72); + }); + }); + + describe('best-practice preset', () => { + it('should return approximately 30 audits', () => { + const audits = transformRulesToAudits(loadAxeRules('best-practice')); + + expect(audits.length).toBeGreaterThanOrEqual(25); + expect(audits.length).toBeLessThanOrEqual(35); + }); + }); + + describe('all preset', () => { + it('should return approximately 104 audits', () => { + const audits = transformRulesToAudits(loadAxeRules('all')); + + expect(audits.length).toBeGreaterThanOrEqual(100); + expect(audits.length).toBeLessThanOrEqual(110); + }); + }); + + describe('audit structure', () => { + it('should have slug, title, description, and docsUrl', () => { + const audit = transformRulesToAudits( + loadAxeRules('wcag21aa'), + )[0] as Audit; + + expect(audit.slug).toBeTruthy(); + expect(audit.title).toBeTruthy(); + expect(audit.description).toBeTruthy(); + expect(audit.docsUrl).toMatch(/^https:\/\//); + }); + }); +}); diff --git a/packages/plugin-axe/src/lib/meta/groups.unit.test.ts b/packages/plugin-axe/src/lib/meta/groups.unit.test.ts new file mode 100644 index 000000000..729da2acd --- /dev/null +++ b/packages/plugin-axe/src/lib/meta/groups.unit.test.ts @@ -0,0 +1,128 @@ +import type { RuleMetadata } from 'axe-core'; +import { describe, expect, it } from 'vitest'; +import { loadAxeRules, transformRulesToGroups } from './transform.js'; + +describe('transformRulesToGroups', () => { + describe('wcag21aa preset', () => { + it('should create WCAG 2.1 Level A and AA groups', () => { + const groups = transformRulesToGroups( + loadAxeRules('wcag21aa'), + 'wcag21aa', + ); + + expect(groups.map(({ slug }) => slug)).toEqual([ + 'wcag21-level-a', + 'wcag21-level-aa', + ]); + expect(groups.map(({ title }) => title)).toEqual([ + 'WCAG 2.1 Level A', + 'WCAG 2.1 Level AA', + ]); + }); + + it('should have refs in WCAG groups', () => { + transformRulesToGroups(loadAxeRules('wcag21aa'), 'wcag21aa').forEach( + ({ refs }) => { + expect(refs.length).toBeGreaterThan(0); + }, + ); + }); + }); + + describe('wcag22aa preset', () => { + it('should create WCAG 2.2 Level A and AA groups', () => { + const groups = transformRulesToGroups( + loadAxeRules('wcag22aa'), + 'wcag22aa', + ); + + expect(groups.map(({ slug }) => slug)).toEqual([ + 'wcag22-level-a', + 'wcag22-level-aa', + ]); + expect(groups.map(({ title }) => title)).toEqual([ + 'WCAG 2.2 Level A', + 'WCAG 2.2 Level AA', + ]); + }); + }); + + describe('best-practice preset', () => { + it('should create multiple category groups', () => { + expect( + transformRulesToGroups(loadAxeRules('best-practice'), 'best-practice') + .length, + ).toBeGreaterThan(5); + }); + + it('should format category titles correctly', () => { + const groups = transformRulesToGroups( + loadAxeRules('best-practice'), + 'best-practice', + ); + + expect(groups.find(({ slug }) => slug === 'aria')?.title).toBe('ARIA'); + expect(groups.find(({ slug }) => slug === 'name-role-value')?.title).toBe( + 'Names & Labels', + ); + }); + + it('should format unknown category titles with title case', () => { + const groups = transformRulesToGroups( + [{ tags: ['cat.some-new-category', 'best-practice'] } as RuleMetadata], + 'best-practice', + ); + + expect( + groups.find(({ slug }) => slug === 'some-new-category')?.title, + ).toBe('Some New Category'); + }); + + it('should remove "cat." prefix from category slugs', () => { + transformRulesToGroups( + loadAxeRules('best-practice'), + 'best-practice', + ).forEach(({ slug }) => { + expect(slug).not.toMatch(/^cat\./); + }); + }); + }); + + describe('all preset', () => { + it('should combine WCAG and category groups', () => { + const groups = transformRulesToGroups(loadAxeRules('all'), 'all'); + + expect(groups.filter(({ slug }) => slug.startsWith('wcag'))).toHaveLength( + 2, + ); + expect( + groups.filter(({ slug }) => !slug.startsWith('wcag')).length, + ).toBeGreaterThan(5); + }); + + it('should use WCAG 2.2 for all preset', () => { + const groups = transformRulesToGroups(loadAxeRules('all'), 'all'); + + expect(groups.some(({ slug }) => slug === 'wcag22-level-a')).toBe(true); + expect(groups.some(({ slug }) => slug === 'wcag22-level-aa')).toBe(true); + }); + }); + + describe('group structure', () => { + it('should have all refs with weight 1', () => { + transformRulesToGroups(loadAxeRules('wcag21aa'), 'wcag21aa').forEach( + ({ refs }) => { + refs.forEach(({ weight }) => { + expect(weight).toBe(1); + }); + }, + ); + }); + + it('should filter out empty groups', () => { + transformRulesToGroups(loadAxeRules('all'), 'all').forEach(({ refs }) => { + expect(refs.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/packages/plugin-axe/src/lib/meta/transform.ts b/packages/plugin-axe/src/lib/meta/transform.ts new file mode 100644 index 000000000..53406c6d5 --- /dev/null +++ b/packages/plugin-axe/src/lib/meta/transform.ts @@ -0,0 +1,146 @@ +import axe from 'axe-core'; +import type { Audit, Group } from '@code-pushup/models'; +import { capitalize } from '@code-pushup/utils'; +import type { AxePreset } from '../constants.js'; + +const WCAG_LEVEL_A_TAGS = ['wcag2a', 'wcag21a']; +const WCAG_LEVEL_AA_TAGS_21 = ['wcag2aa', 'wcag21aa']; +const WCAG_LEVEL_AA_TAGS_22 = ['wcag2aa', 'wcag21aa', 'wcag22aa']; + +const CATEGORY_TITLES: Record = { + 'cat.aria': 'ARIA', + 'cat.color': 'Color & Contrast', + 'cat.forms': 'Forms', + 'cat.keyboard': 'Keyboard', + 'cat.language': 'Language', + 'cat.name-role-value': 'Names & Labels', + 'cat.parsing': 'Parsing', + 'cat.semantics': 'Semantics', + 'cat.sensory-and-visual-cues': 'Visual Cues', + 'cat.structure': 'Structure', + 'cat.tables': 'Tables', + 'cat.text-alternatives': 'Text Alternatives', + 'cat.time-and-media': 'Media', +}; + +export function loadAxeRules(preset: AxePreset): axe.RuleMetadata[] { + const tags = getPresetTags(preset); + return tags.length === 0 ? axe.getRules() : axe.getRules(tags); +} + +export function transformRulesToAudits(rules: axe.RuleMetadata[]): Audit[] { + return rules.map(rule => ({ + slug: rule.ruleId, + title: rule.help, + description: rule.description, + docsUrl: rule.helpUrl, + })); +} + +export function transformRulesToGroups( + rules: axe.RuleMetadata[], + preset: AxePreset, +): Group[] { + const groups = (() => { + switch (preset) { + case 'wcag21aa': + return createWcagGroups(rules, '2.1'); + case 'wcag22aa': + return createWcagGroups(rules, '2.2'); + case 'best-practice': + return createCategoryGroups(rules); + case 'all': + return [ + ...createWcagGroups(rules, '2.2'), + ...createCategoryGroups(rules), + ]; + } + })(); + + return groups.filter(({ refs }) => refs.length > 0); +} + +/** + * Maps preset to corresponding axe-core tags. + * + * WCAG tags are non-cumulative - each rule has exactly one WCAG version tag. + * To include all rules up to a version/level, multiple tags must be combined. + */ +function getPresetTags(preset: AxePreset): string[] { + switch (preset) { + case 'wcag21aa': + return [...WCAG_LEVEL_A_TAGS, ...WCAG_LEVEL_AA_TAGS_21]; + case 'wcag22aa': + return [...WCAG_LEVEL_A_TAGS, ...WCAG_LEVEL_AA_TAGS_22]; + case 'best-practice': + return ['best-practice']; + case 'all': + return []; + } +} + +function createGroup(slug: string, title: string, ruleIds: string[]): Group { + return { + slug, + title, + refs: ruleIds.map(ruleId => ({ slug: ruleId, weight: 1 })), + }; +} + +function createWcagGroups( + rules: axe.RuleMetadata[], + version: '2.1' | '2.2', +): Group[] { + const aTags = WCAG_LEVEL_A_TAGS; + const aaTags = + version === '2.1' ? WCAG_LEVEL_AA_TAGS_21 : WCAG_LEVEL_AA_TAGS_22; + + const levelARuleIds = rules + .filter(({ tags }) => tags.some(tag => aTags.includes(tag))) + .map(({ ruleId }) => ruleId); + + const levelAARuleIds = rules + .filter(({ tags }) => tags.some(tag => aaTags.includes(tag))) + .map(({ ruleId }) => ruleId); + + const versionSlug = version.replace('.', ''); + + return [ + createGroup( + `wcag${versionSlug}-level-a`, + `WCAG ${version} Level A`, + levelARuleIds, + ), + createGroup( + `wcag${versionSlug}-level-aa`, + `WCAG ${version} Level AA`, + levelAARuleIds, + ), + ]; +} + +function createCategoryGroups(rules: axe.RuleMetadata[]): Group[] { + const categoryTags = new Set( + rules.flatMap(({ tags }) => tags.filter(tag => tag.startsWith('cat.'))), + ); + + return Array.from(categoryTags).map(tag => { + const slug = tag.replace('cat.', ''); + const title = formatCategoryTitle(tag, slug); + const ruleIds = rules + .filter(({ tags }) => tags.includes(tag)) + .map(({ ruleId }) => ruleId); + + return createGroup(slug, title, ruleIds); + }); +} + +function formatCategoryTitle(tag: string, slug: string): string { + if (CATEGORY_TITLES[tag]) { + return CATEGORY_TITLES[tag]; + } + return slug + .split('-') + .map(word => capitalize(word)) + .join(' '); +} From f15817b017456dd8c48ddced55457657a155453c Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 5 Nov 2025 10:42:32 -0500 Subject: [PATCH 03/18] feat(plugin-axe): add plugin configuration validation --- packages/plugin-axe/src/lib/config.ts | 18 ++++++ .../plugin-axe/src/lib/config.unit.test.ts | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/plugin-axe/src/lib/config.ts create mode 100644 packages/plugin-axe/src/lib/config.unit.test.ts diff --git a/packages/plugin-axe/src/lib/config.ts b/packages/plugin-axe/src/lib/config.ts new file mode 100644 index 000000000..0fcf32dec --- /dev/null +++ b/packages/plugin-axe/src/lib/config.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { pluginScoreTargetsSchema } from '@code-pushup/models'; +import { AXE_DEFAULT_PRESET, AXE_PRESETS } from './constants'; + +export const axePluginOptionsSchema = z + .object({ + preset: z.enum(AXE_PRESETS).default(AXE_DEFAULT_PRESET).meta({ + description: + 'Accessibility ruleset preset (default: wcag21aa for WCAG 2.1 Level AA compliance)', + }), + scoreTargets: pluginScoreTargetsSchema.optional(), + }) + .meta({ + title: 'AxePluginOptions', + description: 'Configuration options for the Axe plugin', + }); + +export type AxePluginOptions = z.input; diff --git a/packages/plugin-axe/src/lib/config.unit.test.ts b/packages/plugin-axe/src/lib/config.unit.test.ts new file mode 100644 index 000000000..0ccc9fe9f --- /dev/null +++ b/packages/plugin-axe/src/lib/config.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { axePluginOptionsSchema } from './config.js'; + +describe('axePluginOptionsSchema', () => { + it('should accept empty options object with default preset', () => { + expect(axePluginOptionsSchema.parse({}).preset).toBe('wcag21aa'); + }); + + it('should accept wcag21aa preset', () => { + expect(() => + axePluginOptionsSchema.parse({ preset: 'wcag21aa' }), + ).not.toThrow(); + }); + + it('should accept wcag22aa preset', () => { + expect(() => + axePluginOptionsSchema.parse({ preset: 'wcag22aa' }), + ).not.toThrow(); + }); + + it('should accept best-practice preset', () => { + expect(() => + axePluginOptionsSchema.parse({ preset: 'best-practice' }), + ).not.toThrow(); + }); + + it('should accept all preset', () => { + expect(() => axePluginOptionsSchema.parse({ preset: 'all' })).not.toThrow(); + }); + + it('should accept number scoreTargets', () => { + expect(() => + axePluginOptionsSchema.parse({ scoreTargets: 0.99 }), + ).not.toThrow(); + }); + + it('should accept object scoreTargets', () => { + expect(() => + axePluginOptionsSchema.parse({ + scoreTargets: { 'color-contrast': 0.99 }, + }), + ).not.toThrow(); + }); + + it('should throw for invalid preset', () => { + expect(() => axePluginOptionsSchema.parse({ preset: 'wcag3aa' })).toThrow(); + }); + + it('should throw for invalid scoreTargets value', () => { + expect(() => axePluginOptionsSchema.parse({ scoreTargets: 1.5 })).toThrow(); + }); + + it('should throw for negative scoreTargets', () => { + expect(() => + axePluginOptionsSchema.parse({ scoreTargets: -0.1 }), + ).toThrow(); + }); +}); From 4ba86c7fe1948c9941818ae7f7ca464462fe2c0b Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 5 Nov 2025 15:09:02 -0500 Subject: [PATCH 04/18] refactor(models,utils): extract shared URL utilities for plugins --- code-pushup.preset.ts | 4 +- packages/models/docs/models-reference.md | 10 + packages/models/src/index.ts | 2 + packages/models/src/lib/plugin-config.ts | 10 + .../models/src/lib/plugin-config.unit.test.ts | 40 +- packages/plugin-lighthouse/src/index.ts | 6 +- .../src/lib/lighthouse-plugin.ts | 9 +- .../src/lib/merge-categories.ts | 81 +--- .../src/lib/merge-categories.unit.test.ts | 34 -- .../plugin-lighthouse/src/lib/processing.ts | 86 +--- .../src/lib/processing.unit.test.ts | 367 +----------------- .../src/lib/runner/runner.ts | 11 +- packages/plugin-lighthouse/src/lib/types.ts | 9 - packages/utils/src/index.ts | 15 + .../utils/src/lib/plugin-url-aggregation.ts | 107 +++++ .../lib/plugin-url-aggregation.unit.test.ts | 360 +++++++++++++++++ packages/utils/src/lib/plugin-url-config.ts | 52 +++ .../src/lib/plugin-url-config.unit.test.ts | 155 ++++++++ 18 files changed, 795 insertions(+), 563 deletions(-) create mode 100644 packages/utils/src/lib/plugin-url-aggregation.ts create mode 100644 packages/utils/src/lib/plugin-url-aggregation.unit.test.ts create mode 100644 packages/utils/src/lib/plugin-url-config.ts create mode 100644 packages/utils/src/lib/plugin-url-config.unit.test.ts diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 34fb85c10..d77a2292c 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -2,6 +2,7 @@ import type { CategoryConfig, CoreConfig, + PluginUrls, } from './packages/models/src/index.js'; import coveragePlugin, { getNxCoveragePaths, @@ -20,7 +21,6 @@ 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'; @@ -137,7 +137,7 @@ export const jsPackagesCoreConfig = async (): Promise => ({ }); export const lighthouseCoreConfig = async ( - urls: LighthouseUrls, + urls: PluginUrls, ): Promise => { const lhPlugin = await lighthousePlugin(urls); return { diff --git a/packages/models/docs/models-reference.md b/packages/models/docs/models-reference.md index 0782c525c..ebf230a2b 100644 --- a/packages/models/docs/models-reference.md +++ b/packages/models/docs/models-reference.md @@ -1366,6 +1366,16 @@ _Union of the following possible types:_ - _Object with dynamic keys of type_ `string` _and values of type_ `number` (_≥0, ≤1_) (_optional_) +## PluginUrls + +URL(s) to analyze. Single URL, array of URLs, or record of URLs with custom weights + +_Union of the following possible types:_ + +- `string` (_url_) +- `Array` +- _Object with dynamic keys of type_ `string` (_url_) _and values of type_ `number` (_>0_) + ## Report Collect output data diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 95e6b09d8..abb45a239 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -93,10 +93,12 @@ export { pluginContextSchema, pluginMetaSchema, pluginScoreTargetsSchema, + pluginUrlsSchema, type PluginConfig, type PluginContext, type PluginMeta, type PluginScoreTargets, + type PluginUrls, } from './lib/plugin-config.js'; export { auditReportSchema, diff --git a/packages/models/src/lib/plugin-config.ts b/packages/models/src/lib/plugin-config.ts index 423434f8b..e69e1ecec 100644 --- a/packages/models/src/lib/plugin-config.ts +++ b/packages/models/src/lib/plugin-config.ts @@ -72,6 +72,16 @@ export const pluginConfigSchema = pluginMetaSchema export type PluginConfig = z.infer; +export const pluginUrlsSchema = z + .union([z.url(), z.array(z.url()), z.record(z.url(), z.number().positive())]) + .meta({ + title: 'PluginUrls', + description: + 'URL(s) to analyze. Single URL, array of URLs, or record of URLs with custom weights', + }); + +export type PluginUrls = z.infer; + // every listed group ref points to an audit within the plugin export function findMissingSlugsInGroupRefs< T extends { audits: Audit[]; groups?: Group[] }, diff --git a/packages/models/src/lib/plugin-config.unit.test.ts b/packages/models/src/lib/plugin-config.unit.test.ts index 3c6fe2d74..f83747ff7 100644 --- a/packages/models/src/lib/plugin-config.unit.test.ts +++ b/packages/models/src/lib/plugin-config.unit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { type PluginConfig, pluginConfigSchema } from './plugin-config.js'; +import { + type PluginConfig, + pluginConfigSchema, + pluginUrlsSchema, +} from './plugin-config.js'; describe('pluginConfigSchema', () => { it('should accept a valid plugin configuration with all entities', () => { @@ -135,3 +139,37 @@ describe('pluginConfigSchema', () => { ).toThrow('slug has to follow the pattern'); }); }); + +describe('pluginUrlsSchema', () => { + it('should accept a single URL string', () => { + expect(() => pluginUrlsSchema.parse('https://example.com')).not.toThrow(); + }); + + it('should accept an array of URLs', () => { + expect(() => + pluginUrlsSchema.parse([ + 'https://example.com', + 'https://example.com/about', + ]), + ).not.toThrow(); + }); + + it('should accept a weighted object of URLs', () => { + expect(() => + pluginUrlsSchema.parse({ + 'https://example.com': 2, + 'https://example.com/about': 1, + }), + ).not.toThrow(); + }); + + it('should throw for invalid URL', () => { + expect(() => pluginUrlsSchema.parse('not-a-url')).toThrow(); + }); + + it('should throw for array with invalid URL', () => { + expect(() => + pluginUrlsSchema.parse(['https://example.com', 'invalid']), + ).toThrow(); + }); +}); diff --git a/packages/plugin-lighthouse/src/index.ts b/packages/plugin-lighthouse/src/index.ts index cbaeb2af2..0d1525a03 100644 --- a/packages/plugin-lighthouse/src/index.ts +++ b/packages/plugin-lighthouse/src/index.ts @@ -7,11 +7,7 @@ export { LIGHTHOUSE_OUTPUT_PATH, } from './lib/constants.js'; export { lighthouseAuditRef, lighthouseGroupRef } from './lib/utils.js'; -export type { - LighthouseGroupSlug, - LighthouseOptions, - LighthouseUrls, -} from './lib/types.js'; +export type { LighthouseGroupSlug, LighthouseOptions } 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/lighthouse-plugin.ts b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts index 14ecb1176..8a7c49036 100644 --- a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts +++ b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts @@ -1,13 +1,14 @@ import { createRequire } from 'node:module'; -import type { PluginConfig } from '@code-pushup/models'; +import type { PluginConfig, PluginUrls } from '@code-pushup/models'; +import { normalizeUrlInput } from '@code-pushup/utils'; import { LIGHTHOUSE_PLUGIN_SLUG } from './constants.js'; import { normalizeFlags } from './normalize-flags.js'; -import { normalizeUrlInput, processAuditsAndGroups } from './processing.js'; +import { processAuditsAndGroups } from './processing.js'; import { createRunnerFunction } from './runner/runner.js'; -import type { LighthouseOptions, LighthouseUrls } from './types.js'; +import type { LighthouseOptions } from './types.js'; export function lighthousePlugin( - urls: LighthouseUrls, + urls: PluginUrls, flags?: LighthouseOptions, ): PluginConfig { const { diff --git a/packages/plugin-lighthouse/src/lib/merge-categories.ts b/packages/plugin-lighthouse/src/lib/merge-categories.ts index e3f8f795d..cf19eba30 100644 --- a/packages/plugin-lighthouse/src/lib/merge-categories.ts +++ b/packages/plugin-lighthouse/src/lib/merge-categories.ts @@ -1,8 +1,15 @@ import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models'; +import { + type PluginUrlContext, + createCategoryRefs, + expandCategoryRefs, + removeIndex, + shouldExpandForUrls, + validateUrlContext, +} from '@code-pushup/utils'; 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 type { LighthouseGroupSlug } from './types.js'; import { isLighthouseGroupSlug } from './utils.js'; /** @@ -31,7 +38,7 @@ export function mergeLighthouseCategories( if (!plugin.groups || plugin.groups.length === 0) { return categories ?? []; } - validateContext(plugin.context); + validateUrlContext(plugin.context); if (!categories) { return createCategories(plugin.groups, plugin.context); } @@ -40,7 +47,7 @@ export function mergeLighthouseCategories( function createCategories( groups: Group[], - context: LighthouseContext, + context: PluginUrlContext, ): CategoryConfig[] { if (!shouldExpandForUrls(context.urlCount)) { return []; @@ -52,7 +59,7 @@ function createCategories( function expandCategories( categories: CategoryConfig[], - context: LighthouseContext, + context: PluginUrlContext, ): CategoryConfig[] { if (!shouldExpandForUrls(context.urlCount)) { return categories; @@ -68,7 +75,7 @@ function expandCategories( */ export function createAggregatedCategory( groupSlug: LighthouseGroupSlug, - context: LighthouseContext, + context: PluginUrlContext, ): CategoryConfig { const group = LIGHTHOUSE_GROUPS.find(({ slug }) => slug === groupSlug); if (!group) { @@ -81,14 +88,7 @@ export function createAggregatedCategory( 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), - })), + refs: createCategoryRefs(group.slug, LIGHTHOUSE_PLUGIN_SLUG, context), }; } @@ -98,22 +98,15 @@ export function createAggregatedCategory( */ export function expandAggregatedCategory( category: CategoryConfig, - context: LighthouseContext, + context: PluginUrlContext, ): 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]; - }), + refs: category.refs.flatMap(ref => + ref.plugin === LIGHTHOUSE_PLUGIN_SLUG + ? expandCategoryRefs(ref, context) + : [ref], + ), }; } @@ -122,38 +115,6 @@ export function expandAggregatedCategory( * Useful for deduplicating and normalizing group slugs when generating categories. */ export function extractGroupSlugs(groups: Group[]): LighthouseGroupSlug[] { - const slugs = groups.map(({ slug }) => slug.replace(/-\d+$/, '')); + const slugs = groups.map(({ slug }) => removeIndex(slug)); 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 index 44c583842..752363a65 100644 --- a/packages/plugin-lighthouse/src/lib/merge-categories.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/merge-categories.unit.test.ts @@ -2,12 +2,10 @@ 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', () => { @@ -830,35 +828,3 @@ describe('expandAggregatedCategory', () => { ]); }); }); - -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 index d196a7edb..089c6ab77 100644 --- a/packages/plugin-lighthouse/src/lib/processing.ts +++ b/packages/plugin-lighthouse/src/lib/processing.ts @@ -1,88 +1,16 @@ import type { Audit, Group } from '@code-pushup/models'; -import { SINGLE_URL_THRESHOLD } from './constants.js'; +import { + addIndex, + expandAuditsForUrls, + expandGroupsForUrls, + shouldExpandForUrls, +} from '@code-pushup/utils'; 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, @@ -92,7 +20,7 @@ export function expandOptionsForUrls( key, Array.isArray(value) ? value.flatMap(slug => - Array.from({ length: urlCount }, (_, i) => orderSlug(slug, i)), + Array.from({ length: urlCount }, (_, i) => addIndex(slug, i)), ) : value, ]), diff --git a/packages/plugin-lighthouse/src/lib/processing.unit.test.ts b/packages/plugin-lighthouse/src/lib/processing.unit.test.ts index 5447a4faa..eb21a3763 100644 --- a/packages/plugin-lighthouse/src/lib/processing.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/processing.unit.test.ts @@ -1,222 +1,5 @@ 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); - }); -}); +import { expandOptionsForUrls, processAuditsAndGroups } from './processing.js'; describe('expandOptionsForUrls', () => { it('should expand onlyAudits options', () => { @@ -338,151 +121,3 @@ describe('processAuditsAndGroups', () => { 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 252389d00..6e012f63c 100644 --- a/packages/plugin-lighthouse/src/lib/runner/runner.ts +++ b/packages/plugin-lighthouse/src/lib/runner/runner.ts @@ -2,8 +2,13 @@ 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, stringifyError, ui } from '@code-pushup/utils'; -import { orderSlug, shouldExpandForUrls } from '../processing.js'; +import { + addIndex, + ensureDirectoryExists, + shouldExpandForUrls, + stringifyError, + ui, +} from '@code-pushup/utils'; import type { LighthouseOptions } from '../types.js'; import { DEFAULT_CLI_FLAGS } from './constants.js'; import type { LighthouseCliFlags } from './types.js'; @@ -41,7 +46,7 @@ export function createRunnerFunction( ? auditOutputs : auditOutputs.map(audit => ({ ...audit, - slug: orderSlug(audit.slug, index), + slug: addIndex(audit.slug, index), })); return [...acc, ...processedOutputs]; diff --git a/packages/plugin-lighthouse/src/lib/types.ts b/packages/plugin-lighthouse/src/lib/types.ts index 3e1b2bb6a..8b6841dd2 100644 --- a/packages/plugin-lighthouse/src/lib/types.ts +++ b/packages/plugin-lighthouse/src/lib/types.ts @@ -31,12 +31,3 @@ export type LighthouseOptions = ExcludeNullableProps< }; 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/utils/src/index.ts b/packages/utils/src/index.ts index edf9f1963..85f0b564f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -90,6 +90,21 @@ export { logMultipleResults } from './lib/log-results.js'; export { Logger, logger } from './lib/logger.js'; export { link, ui, type CliUi, type Column } from './lib/logging.js'; export { mergeConfigs } from './lib/merge-configs.js'; +export { + normalizeUrlInput, + type PluginUrlContext, +} from './lib/plugin-url-config.js'; +export { + addIndex, + ContextValidationError, + createCategoryRefs, + expandAuditsForUrls, + expandCategoryRefs, + expandGroupsForUrls, + removeIndex, + shouldExpandForUrls, + validateUrlContext, +} from './lib/plugin-url-aggregation.js'; export { getProgressBar, type ProgressBar } from './lib/progress.js'; export { asyncSequential, diff --git a/packages/utils/src/lib/plugin-url-aggregation.ts b/packages/utils/src/lib/plugin-url-aggregation.ts new file mode 100644 index 000000000..5fb75a0e4 --- /dev/null +++ b/packages/utils/src/lib/plugin-url-aggregation.ts @@ -0,0 +1,107 @@ +import type { + Audit, + CategoryRef, + Group, + PluginConfig, +} from '@code-pushup/models'; +import { + type PluginUrlContext, + SINGLE_URL_THRESHOLD, + getUrlIdentifier, +} from './plugin-url-config.js'; + +export function shouldExpandForUrls(urlCount: number): boolean { + return urlCount > SINGLE_URL_THRESHOLD; +} + +export function addIndex(slug: string, index: number): string { + return `${slug}-${index + 1}`; +} + +export function removeIndex(slug: string): string { + return slug.replace(/-\d+$/, ''); +} + +export function resolveUrlWeight( + weights: PluginUrlContext['weights'], + index: number, + userDefinedWeight?: number, +): number { + return weights[index + 1] ?? userDefinedWeight ?? 1; +} + +export function expandAuditsForUrls(audits: Audit[], urls: string[]): Audit[] { + return urls.flatMap((url, index) => + audits.map(audit => ({ + ...audit, + slug: addIndex(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: addIndex(group.slug, index), + title: `${group.title} (${getUrlIdentifier(url)})`, + refs: group.refs.map(ref => ({ + ...ref, + slug: addIndex(ref.slug, index), + })), + })), + ); +} + +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), + })); +} + +export function expandCategoryRefs( + ref: CategoryRef, + context: PluginUrlContext, +): CategoryRef[] { + return Array.from({ length: context.urlCount }, (_, i) => ({ + ...ref, + slug: shouldExpandForUrls(context.urlCount) + ? addIndex(ref.slug, i) + : ref.slug, + 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 new file mode 100644 index 000000000..cfe241d20 --- /dev/null +++ b/packages/utils/src/lib/plugin-url-aggregation.unit.test.ts @@ -0,0 +1,360 @@ +import { + ContextValidationError, + addIndex, + createCategoryRefs, + expandAuditsForUrls, + expandCategoryRefs, + expandGroupsForUrls, + removeIndex, + resolveUrlWeight, + shouldExpandForUrls, + validateUrlContext, +} from './plugin-url-aggregation'; + +describe('shouldExpandForUrls', () => { + it.each([ + [false, 0], + [false, 1], + [true, 2], + [true, 3], + [true, 10], + ])('should return %j for urlCount %d', (expected, urlCount) => { + expect(shouldExpandForUrls(urlCount)).toBe(expected); + }); +}); + +describe('addIndex', () => { + 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(addIndex(slug, index)).toBe(expected); + }); +}); + +describe('removeIndex', () => { + it.each([ + ['performance-1', 'performance'], + ['performance-2', 'performance'], + ['best-practices-10', 'best-practices'], + ['performance', 'performance'], + ['my-slug', 'my-slug'], + ])('should remove suffix from %j to get %j', (input, expected) => { + expect(removeIndex(input)).toBe(expected); + }); +}); + +describe('resolveUrlWeight', () => { + it('should return weight from context', () => { + 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', () => { + 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); + }); +}); + +describe('expandAuditsForUrls', () => { + const mockAudits = [ + { + 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 = { + 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 = [ + { + 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 = { + 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', () => { + 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 }, + ]); + }); + + it('should create refs for single URL without expansion', () => { + expect( + createCategoryRefs('performance', 'lighthouse', { + urlCount: 1, + weights: { 1: 1 }, + }), + ).toEqual([ + { plugin: 'lighthouse', slug: 'performance', type: 'group', 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); + }); +}); + +describe('expandCategoryRefs', () => { + it('should expand ref for multiple URLs with slug ordering', () => { + expect( + expandCategoryRefs( + { + 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 }, + ]); + }); + + it('should not expand for single URL', () => { + expect( + expandCategoryRefs( + { + plugin: 'lighthouse', + slug: 'performance', + type: 'group', + weight: 1, + }, + { urlCount: 1, weights: { 1: 5 } }, + ), + ).toEqual([ + { plugin: 'lighthouse', slug: 'performance', type: 'group', weight: 5 }, + ]); + }); + + 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 work with audit refs', () => { + expect( + expandCategoryRefs( + { + plugin: 'lighthouse', + slug: 'fcp', + type: 'audit', + weight: 1, + }, + { urlCount: 2, weights: { 1: 1, 2: 1 } }, + ), + ).toEqual([ + { 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'), + ); + }); + + 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 throw error for invalid context (missing weights)', () => { + expect(() => validateUrlContext({ urlCount: 2 })).toThrow( + new ContextValidationError('weights must be an object'), + ); + }); + + 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 accept valid context', () => { + expect(() => + validateUrlContext({ urlCount: 2, weights: { 1: 1, 2: 1 } }), + ).not.toThrow(); + }); +}); diff --git a/packages/utils/src/lib/plugin-url-config.ts b/packages/utils/src/lib/plugin-url-config.ts new file mode 100644 index 000000000..d575e30f1 --- /dev/null +++ b/packages/utils/src/lib/plugin-url-config.ts @@ -0,0 +1,52 @@ +import type { PluginUrls } from '@code-pushup/models'; + +export type PluginUrlContext = { + urlCount: number; + weights: Record; +}; + +export const SINGLE_URL_THRESHOLD = 1; + +export function normalizeUrlInput(input: PluginUrls): { + urls: string[]; + context: PluginUrlContext; +} { + if (typeof input === 'string') { + return { + urls: [input], + context: { + urlCount: 1, + weights: { 1: 1 }, + }, + }; + } + if (Array.isArray(input)) { + return { + urls: input, + context: { + urlCount: input.length, + weights: Object.fromEntries(input.map((_, i) => [i + 1, 1])), + }, + }; + } + const entries = Object.entries(input); + return { + urls: entries.map(([url]) => url), + context: { + urlCount: entries.length, + weights: Object.fromEntries( + entries.map(([, weight], i) => [i + 1, weight]), + ), + }, + }; +} + +export function getUrlIdentifier(url: string): string { + try { + const { host, pathname } = new URL(url); + const path = pathname === '/' ? '' : pathname; + return `${host}${path}`; + } catch { + return url; + } +} diff --git a/packages/utils/src/lib/plugin-url-config.unit.test.ts b/packages/utils/src/lib/plugin-url-config.unit.test.ts new file mode 100644 index 000000000..54c356953 --- /dev/null +++ b/packages/utils/src/lib/plugin-url-config.unit.test.ts @@ -0,0 +1,155 @@ +import { getUrlIdentifier, normalizeUrlInput } from './plugin-url-config'; + +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('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('weighted object 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 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 }); + }); + }); +}); From d9d0a6e096f9cf18c3645a544028896c664e4cf7 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 5 Nov 2025 21:25:16 -0500 Subject: [PATCH 05/18] feat(plugin-axe): add audits and groups processing --- packages/plugin-axe/src/lib/processing.ts | 36 +++++++++++++++++++ .../src/lib/processing.unit.test.ts | 33 +++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 packages/plugin-axe/src/lib/processing.ts create mode 100644 packages/plugin-axe/src/lib/processing.unit.test.ts diff --git a/packages/plugin-axe/src/lib/processing.ts b/packages/plugin-axe/src/lib/processing.ts new file mode 100644 index 000000000..b5cdfa6e4 --- /dev/null +++ b/packages/plugin-axe/src/lib/processing.ts @@ -0,0 +1,36 @@ +import type { Audit, Group } from '@code-pushup/models'; +import { + expandAuditsForUrls, + expandGroupsForUrls, + shouldExpandForUrls, +} from '@code-pushup/utils'; +import type { AxePreset } from './constants'; +import { + loadAxeRules, + transformRulesToAudits, + transformRulesToGroups, +} from './meta/transform'; + +export function processAuditsAndGroups( + urls: string[], + preset: AxePreset, +): { + audits: Audit[]; + groups: Group[]; + ruleIds: string[]; +} { + const rules = loadAxeRules(preset); + const ruleIds = rules.map(({ ruleId }) => ruleId); + const audits = transformRulesToAudits(rules); + const groups = transformRulesToGroups(rules, preset); + + if (!shouldExpandForUrls(urls.length)) { + return { audits, groups, ruleIds }; + } + + return { + audits: expandAuditsForUrls(audits, urls), + groups: expandGroupsForUrls(groups, urls), + ruleIds, + }; +} diff --git a/packages/plugin-axe/src/lib/processing.unit.test.ts b/packages/plugin-axe/src/lib/processing.unit.test.ts new file mode 100644 index 000000000..81ff32fd3 --- /dev/null +++ b/packages/plugin-axe/src/lib/processing.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { processAuditsAndGroups } from './processing'; + +describe('processAuditsAndGroups', () => { + it('should return audits and groups without expansion for single URL', () => { + const { audits, groups } = processAuditsAndGroups( + ['https://example.com'], + 'wcag21aa', + ); + + expect(audits.length).toBeGreaterThan(0); + expect(groups.length).toBeGreaterThan(0); + + expect(audits[0]?.slug).not.toContain('-1'); + expect(groups[0]?.slug).not.toContain('-1'); + }); + + it('should expand audits and groups for multiple URLs', () => { + const { audits, groups } = processAuditsAndGroups( + ['https://example.com', 'https://another-example.com'], + 'wcag21aa', + ); + + expect(audits.length).toBeGreaterThan(0); + expect(groups.length).toBeGreaterThan(0); + + expect(audits[0]?.slug).toContain('-1'); + expect(groups[0]?.slug).toContain('-1'); + + expect(audits[0]?.title).toContain('(example.com)'); + expect(groups[0]?.title).toContain('(example.com)'); + }); +}); From 128187338faf90f69d5c3751b6ec4a88ff0166a3 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 6 Nov 2025 14:21:33 -0500 Subject: [PATCH 06/18] feat(plugin-axe): implement runner with Playwright/Axe integration feat(plugin-axe): implement runner with Playwright/Axe integration --- packages/plugin-axe/README.md | 7 + packages/plugin-axe/src/index.ts | 4 +- packages/plugin-axe/src/lib/axe-plugin.ts | 48 +++- packages/plugin-axe/src/lib/runner/run-axe.ts | 62 +++++ packages/plugin-axe/src/lib/runner/runner.ts | 65 +++++ .../src/lib/runner/runner.unit.test.ts | 141 +++++++++++ .../plugin-axe/src/lib/runner/transform.ts | 102 ++++++++ .../src/lib/runner/transform.unit.test.ts | 231 ++++++++++++++++++ packages/plugin-axe/tsconfig.test.json | 8 +- packages/plugin-axe/vitest.int.config.ts | 30 +++ packages/plugin-axe/vitest.unit.config.ts | 2 +- 11 files changed, 689 insertions(+), 11 deletions(-) create mode 100644 packages/plugin-axe/README.md create mode 100644 packages/plugin-axe/src/lib/runner/run-axe.ts create mode 100644 packages/plugin-axe/src/lib/runner/runner.ts create mode 100644 packages/plugin-axe/src/lib/runner/runner.unit.test.ts create mode 100644 packages/plugin-axe/src/lib/runner/transform.ts create mode 100644 packages/plugin-axe/src/lib/runner/transform.unit.test.ts create mode 100644 packages/plugin-axe/vitest.int.config.ts diff --git a/packages/plugin-axe/README.md b/packages/plugin-axe/README.md new file mode 100644 index 000000000..97dcf4930 --- /dev/null +++ b/packages/plugin-axe/README.md @@ -0,0 +1,7 @@ +# plugin-axe + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test plugin-axe` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/packages/plugin-axe/src/index.ts b/packages/plugin-axe/src/index.ts index 8f365ef00..3e6d05242 100644 --- a/packages/plugin-axe/src/index.ts +++ b/packages/plugin-axe/src/index.ts @@ -1 +1,3 @@ -export * from './lib/axe-plugin.js'; +import { axePlugin } from './lib/axe-plugin.js'; + +export default axePlugin; diff --git a/packages/plugin-axe/src/lib/axe-plugin.ts b/packages/plugin-axe/src/lib/axe-plugin.ts index 452bdc85d..ffc4ab3f6 100644 --- a/packages/plugin-axe/src/lib/axe-plugin.ts +++ b/packages/plugin-axe/src/lib/axe-plugin.ts @@ -1,3 +1,47 @@ -export function axePlugin(): string { - return 'plugin-axe'; +import { createRequire } from 'node:module'; +import type { PluginConfig, PluginUrls } from '@code-pushup/models'; +import { normalizeUrlInput } from '@code-pushup/utils'; +import type { AxePluginOptions } from './config'; +import { AXE_DEFAULT_PRESET, AXE_PLUGIN_SLUG } from './constants'; +import { processAuditsAndGroups } from './processing'; +import { createRunnerFunction } from './runner/runner'; + +/** + * Code PushUp plugin for accessibility testing using axe-core. + * + * @public + * @param urls - {@link PluginUrls} URL(s) to test + * @param options - {@link AxePluginOptions} Plugin options + * @returns Plugin configuration + */ +export function axePlugin( + urls: PluginUrls, + options?: AxePluginOptions, +): PluginConfig { + const scoreTargets = options?.scoreTargets; + const preset = options?.preset ?? AXE_DEFAULT_PRESET; + + const { urls: normalizedUrls, context } = normalizeUrlInput(urls); + + const { audits, groups, ruleIds } = processAuditsAndGroups( + normalizedUrls, + preset, + ); + + const packageJson = createRequire(import.meta.url)( + '../../package.json', + ) as typeof import('../../package.json'); + + return { + slug: AXE_PLUGIN_SLUG, + packageName: packageJson.name, + version: packageJson.version, + title: 'Axe Accessibility', + icon: 'folder-syntax', + audits, + groups, + runner: createRunnerFunction(normalizedUrls, ruleIds), + context, + ...(scoreTargets && { scoreTargets }), + }; } diff --git a/packages/plugin-axe/src/lib/runner/run-axe.ts b/packages/plugin-axe/src/lib/runner/run-axe.ts new file mode 100644 index 000000000..160c6eb08 --- /dev/null +++ b/packages/plugin-axe/src/lib/runner/run-axe.ts @@ -0,0 +1,62 @@ +import AxeBuilder from '@axe-core/playwright'; +import { type Browser, chromium } from 'playwright-core'; +import type { AuditOutputs } from '@code-pushup/models'; +import { logger, stringifyError } from '@code-pushup/utils'; +import { toAuditOutputs } from './transform.js'; + +let browser: Browser | undefined; + +export async function runAxeForUrl( + url: string, + ruleIds: string[], +): Promise { + try { + if (!browser) { + logger.debug('Launching Chromium browser...'); + browser = await chromium.launch({ headless: true }); + } + + const context = await browser.newContext(); + + try { + const page = await context.newPage(); + try { + await page.goto(url, { + waitUntil: 'networkidle', + timeout: 30_000, + }); + + const axeBuilder = new AxeBuilder({ page }); + + // Use withRules() to include experimental/deprecated rules + if (ruleIds.length > 0) { + axeBuilder.withRules(ruleIds); + } + + const results = await axeBuilder.analyze(); + + if (results.incomplete.length > 0) { + logger.warn( + `Axe returned ${results.incomplete.length} incomplete result(s) for ${url}`, + ); + } + + return toAuditOutputs(results, url); + } finally { + await page.close(); + } + } finally { + await context.close(); + } + } catch (error) { + logger.error(`Axe execution failed for ${url}: ${stringifyError(error)}`); + throw error; + } +} + +export async function closeBrowser(): Promise { + if (browser) { + await browser.close(); + browser = undefined; + } +} diff --git a/packages/plugin-axe/src/lib/runner/runner.ts b/packages/plugin-axe/src/lib/runner/runner.ts new file mode 100644 index 000000000..469184063 --- /dev/null +++ b/packages/plugin-axe/src/lib/runner/runner.ts @@ -0,0 +1,65 @@ +import type { + AuditOutputs, + RunnerArgs, + RunnerFunction, +} from '@code-pushup/models'; +import { + addIndex, + logger, + shouldExpandForUrls, + stringifyError, +} from '@code-pushup/utils'; +import { closeBrowser, runAxeForUrl } from './run-axe.js'; + +export function createRunnerFunction( + urls: string[], + ruleIds: string[], +): RunnerFunction { + return async (_runnerArgs?: RunnerArgs): Promise => { + const isSingleUrl = !shouldExpandForUrls(urls.length); + + logger.info( + `Running Axe accessibility checks for ${urls.length} URL(s)...`, + ); + + try { + const allResults = await urls.reduce(async (prev, url, index) => { + const acc = await prev; + + logger.debug(`Testing URL ${index + 1}/${urls.length}: ${url}`); + + try { + const auditOutputs = await runAxeForUrl(url, ruleIds); + + const processedOutputs = isSingleUrl + ? auditOutputs + : auditOutputs.map(audit => ({ + ...audit, + slug: addIndex(audit.slug, index), + })); + + return [...acc, ...processedOutputs]; + } catch (error) { + logger.warn(stringifyError(error)); + return acc; + } + }, Promise.resolve([])); + + if (allResults.length === 0) { + throw new Error( + isSingleUrl + ? 'Axe did not produce any results.' + : 'Axe failed to produce results for all URLs.', + ); + } + + logger.info( + `Completed Axe accessibility checks with ${allResults.length} audit(s)`, + ); + + return allResults; + } finally { + await closeBrowser(); + } + }; +} diff --git a/packages/plugin-axe/src/lib/runner/runner.unit.test.ts b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts new file mode 100644 index 000000000..82aa03bee --- /dev/null +++ b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type AuditOutput, DEFAULT_PERSIST_CONFIG } from '@code-pushup/models'; +import * as runAxe from './run-axe.js'; +import { createRunnerFunction } from './runner.js'; + +vi.mock('./run-axe.js', () => ({ + runAxeForUrl: vi.fn(), + closeBrowser: vi.fn(), +})); + +describe('createRunnerFunction', () => { + const mockRunAxeForUrl = vi.mocked(runAxe.runAxeForUrl); + const mockCloseBrowser = vi.mocked(runAxe.closeBrowser); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockAuditOutput = (slug: string, score = 1): AuditOutput => ({ + slug, + score, + value: 0, + displayValue: 'No violations found', + }); + + it('should handle single URL without adding index to audit slugs', async () => { + const mockResults = [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('html-has-lang'), + ]; + mockRunAxeForUrl.mockResolvedValue(mockResults); + + const runnerFn = createRunnerFunction(['https://example.com'], []); + const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); + + expect(mockRunAxeForUrl).toHaveBeenCalledWith('https://example.com', []); + expect(mockCloseBrowser).toHaveBeenCalled(); + expect(results).toEqual(mockResults); + }); + + it('should handle multiple URLs and add index to audit slugs', async () => { + const mockResults1 = [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('html-has-lang'), + ]; + const mockResults2 = [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('color-contrast'), + ]; + + mockRunAxeForUrl + .mockResolvedValueOnce(mockResults1) + .mockResolvedValueOnce(mockResults2); + + const runnerFn = createRunnerFunction( + ['https://example.com', 'https://another-example.org'], + [], + ); + const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); + + expect(mockRunAxeForUrl).toHaveBeenCalledTimes(2); + expect(mockRunAxeForUrl).toHaveBeenNthCalledWith( + 1, + 'https://example.com', + [], + ); + expect(mockRunAxeForUrl).toHaveBeenNthCalledWith( + 2, + 'https://another-example.org', + [], + ); + expect(mockCloseBrowser).toHaveBeenCalled(); + + expect(results).toHaveLength(4); + expect(results.map(({ slug }) => slug)).toEqual([ + 'image-alt-1', + 'html-has-lang-1', + 'image-alt-2', + 'color-contrast-2', + ]); + }); + + it('should pass ruleIds to runAxeForUrl', async () => { + const mockResults = [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('html-has-lang'), + ]; + mockRunAxeForUrl.mockResolvedValue(mockResults); + + const ruleIds = ['image-alt', 'html-has-lang']; + const runnerFn = createRunnerFunction(['https://example.com'], ruleIds); + await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); + + expect(mockRunAxeForUrl).toHaveBeenCalledWith( + 'https://example.com', + ruleIds, + ); + }); + + it('should continue with other URLs when one fails in multiple URL scenario', async () => { + const mockResults = [createMockAuditOutput('image-alt')]; + + mockRunAxeForUrl + .mockRejectedValueOnce(new Error('Failed to load page')) + .mockResolvedValueOnce(mockResults); + + const runnerFn = createRunnerFunction( + ['https://broken.com', 'https://working.com'], + [], + ); + const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); + + expect(mockRunAxeForUrl).toHaveBeenCalledTimes(2); + expect(mockCloseBrowser).toHaveBeenCalled(); + expect(results).toHaveLength(1); + expect(results[0]?.slug).toBe('image-alt-2'); + }); + + it('should throw error if all URLs fail in multiple URL scenario', async () => { + mockRunAxeForUrl.mockRejectedValue(new Error('Failed to load page')); + + const runnerFn = createRunnerFunction( + ['https://example.com', 'https://another-example.com'], + [], + ); + + await expect(runnerFn({ persist: DEFAULT_PERSIST_CONFIG })).rejects.toThrow( + 'Axe failed to produce results for all URLs.', + ); + }); + + it('should throw error when single URL fails', async () => { + mockRunAxeForUrl.mockRejectedValue(new Error('Failed to load page')); + + const runnerFn = createRunnerFunction(['https://example.com'], []); + + await expect(runnerFn({ persist: DEFAULT_PERSIST_CONFIG })).rejects.toThrow( + 'Axe did not produce any results.', + ); + }); +}); diff --git a/packages/plugin-axe/src/lib/runner/transform.ts b/packages/plugin-axe/src/lib/runner/transform.ts new file mode 100644 index 000000000..c41535b68 --- /dev/null +++ b/packages/plugin-axe/src/lib/runner/transform.ts @@ -0,0 +1,102 @@ +import type { AxeResults, ImpactValue, NodeResult, Result } from 'axe-core'; +import type { + AuditOutput, + AuditOutputs, + Issue, + IssueSeverity, +} from '@code-pushup/models'; +import { + countOccurrences, + objectToEntries, + pluralizeToken, + truncateIssueMessage, +} from '@code-pushup/utils'; + +/** + * Transforms Axe results into audit outputs. + * Priority: violations > incomplete > passes > inapplicable + */ +export function toAuditOutputs( + { passes, violations, incomplete, inapplicable }: AxeResults, + url: string, +): AuditOutputs { + const auditMap = new Map([ + ...inapplicable.map(res => [res.id, toAuditOutput(res, url, 1)] as const), + ...passes.map(res => [res.id, toAuditOutput(res, url, 1)] as const), + ...incomplete.map(res => [res.id, toAuditOutput(res, url, 0)] as const), + ...violations.map(res => [res.id, toAuditOutput(res, url, 0)] as const), + ]); + + return Array.from(auditMap.values()); +} + +/** + * For failing audits (score 0), includes detailed issues with locations and severities. + * For passing audits (score 1), only includes element count. + */ +function toAuditOutput( + result: Result, + url: string, + score: number, +): AuditOutput { + const base = { + slug: result.id, + score, + value: result.nodes.length, + }; + + if (score === 0 && result.nodes.length > 0) { + const issues = result.nodes.map(node => toIssue(node, result, url)); + + return { + ...base, + displayValue: formatSeverityCounts(issues), + details: { issues }, + }; + } + + return { + ...base, + displayValue: pluralizeToken('element', result.nodes.length), + }; +} + +function formatSeverityCounts(issues: Issue[]): string { + const severityCounts = countOccurrences( + issues.map(({ severity }) => severity), + ); + + return objectToEntries(severityCounts) + .toSorted(([a], [b]) => { + const order = { error: 0, warning: 1, info: 2 }; + return order[a] - order[b]; + }) + .map(([severity, count = 0]) => pluralizeToken(severity, count)) + .join(', '); +} + +function toIssue(node: NodeResult, result: Result, url: string): Issue { + const selector = node.target?.[0] || node.html; + const rawMessage = node.failureSummary || result.help; + const cleanedMessage = rawMessage.replace(/\s+/g, ' ').trim(); + + const message = `[${selector}] ${cleanedMessage} (${url})`; + + return { + message: truncateIssueMessage(message), + severity: impactToSeverity(node.impact), + }; +} + +function impactToSeverity(impact?: ImpactValue): IssueSeverity { + switch (impact) { + case 'critical': + case 'serious': + return 'error'; + case 'moderate': + return 'warning'; + case 'minor': + default: + return 'info'; + } +} diff --git a/packages/plugin-axe/src/lib/runner/transform.unit.test.ts b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts new file mode 100644 index 000000000..3fc96b63f --- /dev/null +++ b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts @@ -0,0 +1,231 @@ +import type { AxeResults, NodeResult, Result } from 'axe-core'; +import { describe, expect, it } from 'vitest'; +import type { AuditOutput } from '@code-pushup/models'; +import { toAuditOutputs } from './transform.js'; + +function createMockNode(overrides: Partial = {}): NodeResult { + return { + html: '
', + target: ['div'], + ...overrides, + } as NodeResult; +} + +function createMockResult(id: string, nodes = [createMockNode()]): Result { + return { + id, + description: `Mock description for ${id}`, + help: `Mock help for ${id}`, + helpUrl: `https://example.com/${id}`, + tags: ['wcag2a'], + nodes, + } as Result; +} + +function createMockAxeResults(overrides: Partial = {}): AxeResults { + return { + passes: [], + violations: [], + incomplete: [], + inapplicable: [], + ...overrides, + } as AxeResults; +} + +describe('toAuditOutputs', () => { + const testUrl = 'https://example.com'; + + it('should transform passes with score 1 and no issues', () => { + const results = createMockAxeResults({ + passes: [ + createMockResult('color-contrast', [ + createMockNode(), + createMockNode(), + createMockNode(), + ]), + ], + }); + + expect(toAuditOutputs(results, testUrl)).toEqual([ + { + slug: 'color-contrast', + score: 1, + value: 3, + displayValue: '3 elements', + }, + ]); + }); + + it('should transform violations with score 0 and include issues', () => { + const results = createMockAxeResults({ + violations: [ + createMockResult('image-alt', [ + createMockNode({ + html: '', + target: ['img'], + impact: 'critical', + failureSummary: 'Fix this: Element does not have an alt attribute', + }), + createMockNode({ + html: '', + target: ['.header > img:nth-child(2)'], + impact: 'serious', + failureSummary: 'Fix this: Element does not have an alt attribute', + }), + createMockNode({ + html: '', + target: ['#main img'], + impact: 'critical', + }), + ]), + ], + }); + + expect(toAuditOutputs(results, testUrl)).toEqual([ + { + slug: 'image-alt', + score: 0, + value: 3, + displayValue: '3 errors', + details: { + issues: [ + { + message: + '[img] Fix this: Element does not have an alt attribute (https://example.com)', + severity: 'error', + }, + { + message: + '[.header > img:nth-child(2)] Fix this: Element does not have an alt attribute (https://example.com)', + severity: 'error', + }, + { + message: + '[#main img] Mock help for image-alt (https://example.com)', + severity: 'error', + }, + ], + }, + }, + ]); + }); + + it('should transform incomplete with score 0 and include issues', () => { + const results = createMockAxeResults({ + incomplete: [ + createMockResult('color-contrast', [ + createMockNode({ + html: '', + target: ['button'], + impact: 'moderate', + failureSummary: 'Fix this: Element has insufficient color contrast', + }), + createMockNode({ + html: 'Link', + target: ['a'], + impact: 'moderate', + failureSummary: 'Review: Unable to determine contrast ratio', + }), + ]), + ], + }); + + expect(toAuditOutputs(results, testUrl)).toEqual([ + { + slug: 'color-contrast', + score: 0, + value: 2, + displayValue: '2 warnings', + details: { + issues: [ + { + message: + '[button] Fix this: Element has insufficient color contrast (https://example.com)', + severity: 'warning', + }, + { + message: + '[a] Review: Unable to determine contrast ratio (https://example.com)', + severity: 'warning', + }, + ], + }, + }, + ]); + }); + + it('should transform inapplicable with score 1 and no issues', () => { + const results = createMockAxeResults({ + inapplicable: [createMockResult('audio-caption', [])], + }); + + expect(toAuditOutputs(results, testUrl)).toEqual([ + { + slug: 'audio-caption', + score: 1, + value: 0, + displayValue: '0 elements', + }, + ]); + }); + + it('should deduplicate audits with priority: violations > incomplete > passes > inapplicable', () => { + const results = createMockAxeResults({ + inapplicable: [createMockResult('color-contrast', [])], + passes: [ + createMockResult('color-contrast', [ + createMockNode(), + createMockNode(), + createMockNode(), + ]), + ], + incomplete: [ + createMockResult('color-contrast', [ + createMockNode({ impact: 'moderate' }), + createMockNode({ impact: 'moderate' }), + ]), + ], + violations: [ + createMockResult('color-contrast', [ + createMockNode({ impact: 'critical' }), + ]), + ], + }); + + const outputs = toAuditOutputs(results, testUrl); + + expect(outputs).toHaveLength(1); + expect(outputs[0]).toMatchObject({ + slug: 'color-contrast', + score: 0, + value: 1, + displayValue: '1 error', + }); + }); + + it('should handle empty results', () => { + expect(toAuditOutputs(createMockAxeResults(), testUrl)).toEqual([]); + }); + + it('should format severity counts when multiple impacts exist', () => { + const results = createMockAxeResults({ + violations: [ + createMockResult('color-contrast', [ + createMockNode({ impact: 'critical' }), + createMockNode({ impact: 'serious' }), + createMockNode({ impact: 'moderate' }), + createMockNode({ impact: 'minor' }), + ]), + ], + }); + + const outputs = toAuditOutputs(results, testUrl); + + expect(outputs[0]).toMatchObject({ + slug: 'color-contrast', + score: 0, + value: 4, + displayValue: '2 errors, 1 warning, 1 info', + }); + }); +}); diff --git a/packages/plugin-axe/tsconfig.test.json b/packages/plugin-axe/tsconfig.test.json index 5bbe011ce..7637648e9 100644 --- a/packages/plugin-axe/tsconfig.test.json +++ b/packages/plugin-axe/tsconfig.test.json @@ -2,13 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": [ - "vitest/globals", - "vitest/importMeta", - "vite/client", - "node", - "vitest" - ] + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] }, "include": ["vitest.unit.config.ts", "src/**/*.test.ts"] } diff --git a/packages/plugin-axe/vitest.int.config.ts b/packages/plugin-axe/vitest.int.config.ts new file mode 100644 index 000000000..ebe398b16 --- /dev/null +++ b/packages/plugin-axe/vitest.int.config.ts @@ -0,0 +1,30 @@ +/// +import { defineConfig } from 'vitest/config'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-axe', + test: { + reporters: ['basic'], + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: '../../coverage/plugin-axe/int-tests', + exclude: ['mocks/**', 'vitest.{unit,int}.config.ts'], + }, + environment: 'node', + include: ['src/**/*.int.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + globalSetup: ['../../global-setup.ts'], + setupFiles: [ + '../../testing/test-setup/src/lib/console.mock.ts', + '../../testing/test-setup/src/lib/logger.mock.ts', + '../../testing/test-setup/src/lib/reset.mocks.ts', + ], + }, +}); diff --git a/packages/plugin-axe/vitest.unit.config.ts b/packages/plugin-axe/vitest.unit.config.ts index b21131759..3ca9d3b8d 100644 --- a/packages/plugin-axe/vitest.unit.config.ts +++ b/packages/plugin-axe/vitest.unit.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ coverage: { reporter: ['text', 'lcov'], reportsDirectory: '../../coverage/plugin-axe/unit-tests', - exclude: ['mocks/**', '**/types.ts'], + exclude: ['mocks/**', 'vitest.{unit,int}.config.ts'], }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], From 91e52de20f5838fe04a0125bcec6d3ccab071b09 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Fri, 7 Nov 2025 14:55:33 -0500 Subject: [PATCH 07/18] feat(utils,plugin-axe): improve report formatting --- .../plugin-axe/src/lib/runner/transform.ts | 10 ++- .../src/lib/runner/transform.unit.test.ts | 67 +++++++++++++++++++ packages/utils/src/lib/reports/formatting.ts | 20 ++++-- .../src/lib/reports/formatting.unit.test.ts | 19 ++++++ .../generate-md-report-category-section.ts | 4 +- .../src/lib/reports/generate-md-report.ts | 10 +-- 6 files changed, 119 insertions(+), 11 deletions(-) diff --git a/packages/plugin-axe/src/lib/runner/transform.ts b/packages/plugin-axe/src/lib/runner/transform.ts index c41535b68..102042447 100644 --- a/packages/plugin-axe/src/lib/runner/transform.ts +++ b/packages/plugin-axe/src/lib/runner/transform.ts @@ -1,4 +1,5 @@ import type { AxeResults, ImpactValue, NodeResult, Result } from 'axe-core'; +import type axe from 'axe-core'; import type { AuditOutput, AuditOutputs, @@ -75,8 +76,15 @@ function formatSeverityCounts(issues: Issue[]): string { .join(', '); } +function formatSelector(selector: axe.CrossTreeSelector): string { + if (typeof selector === 'string') { + return selector; + } + return selector.join(' >> '); +} + function toIssue(node: NodeResult, result: Result, url: string): Issue { - const selector = node.target?.[0] || node.html; + const selector = formatSelector(node.target?.[0] || node.html); const rawMessage = node.failureSummary || result.help; const cleanedMessage = rawMessage.replace(/\s+/g, ' ').trim(); diff --git a/packages/plugin-axe/src/lib/runner/transform.unit.test.ts b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts index 3fc96b63f..28559eb39 100644 --- a/packages/plugin-axe/src/lib/runner/transform.unit.test.ts +++ b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts @@ -228,4 +228,71 @@ describe('toAuditOutputs', () => { displayValue: '2 errors, 1 warning, 1 info', }); }); + + it('should format shadow DOM selectors with >> notation', () => { + const results = createMockAxeResults({ + violations: [ + createMockResult('color-contrast', [ + createMockNode({ + html: '', + target: [['#app', 'my-component', 'button']], + impact: 'critical', + failureSummary: 'Fix this: Element has insufficient color contrast', + }), + ]), + ], + }); + + expect(toAuditOutputs(results, testUrl)).toEqual([ + { + slug: 'color-contrast', + score: 0, + value: 1, + displayValue: '1 error', + details: { + issues: [ + { + message: + '[#app >> my-component >> button] Fix this: Element has insufficient color contrast (https://example.com)', + severity: 'error', + }, + ], + }, + }, + ]); + }); + + it('should fall back to html when target is missing', () => { + const results = createMockAxeResults({ + violations: [ + createMockResult('aria-roles', [ + createMockNode({ + html: '
Content
', + target: undefined, + impact: 'serious', + failureSummary: + 'Fix this: Ensure all values assigned to role="" correspond to valid ARIA roles', + }), + ]), + ], + }); + + expect(toAuditOutputs(results, testUrl)).toEqual([ + { + slug: 'aria-roles', + score: 0, + value: 1, + displayValue: '1 error', + details: { + issues: [ + { + message: + '[
Content
] Fix this: Ensure all values assigned to role="" correspond to valid ARIA roles (https://example.com)', + severity: 'error', + }, + ], + }, + }, + ]); + }); }); diff --git a/packages/utils/src/lib/reports/formatting.ts b/packages/utils/src/lib/reports/formatting.ts index 832107fd5..703973e4f 100644 --- a/packages/utils/src/lib/reports/formatting.ts +++ b/packages/utils/src/lib/reports/formatting.ts @@ -73,13 +73,14 @@ export function metaDescription( if (!description) { return docsLink; } - const parsedDescription = description.endsWith('```') - ? `${description}\n\n` - : `${description} `; + const formattedDescription = wrapTags(description); + const parsedDescription = formattedDescription.endsWith('```') + ? `${formattedDescription}\n\n` + : `${formattedDescription} `; return md`${parsedDescription}${docsLink}`; } if (description && description.trim().length > 0) { - return description; + return wrapTags(description); } return ''; } @@ -171,3 +172,14 @@ export function formatFileLink( return relativePath; } } + +/** + * Wraps HTML tags in backticks to prevent markdown parsers + * from interpreting them as actual HTML. + */ +export function wrapTags(text?: string): string { + if (!text) { + return ''; + } + return text.replace(/<[a-z][a-z0-9]*[^>]*>/gi, '`$&`'); +} diff --git a/packages/utils/src/lib/reports/formatting.unit.test.ts b/packages/utils/src/lib/reports/formatting.unit.test.ts index c814d2cd6..de98ec790 100644 --- a/packages/utils/src/lib/reports/formatting.unit.test.ts +++ b/packages/utils/src/lib/reports/formatting.unit.test.ts @@ -8,6 +8,7 @@ import { linkToLocalSourceForIde, metaDescription, tableSection, + wrapTags, } from './formatting.js'; describe('tableSection', () => { @@ -360,3 +361,21 @@ describe('formatFileLink', () => { ).toBe('../src/index.ts'); }); }); + +describe('wrapTags', () => { + it.each([ + ['