diff --git a/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index a1de39aba..781181f86 100644 --- a/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -1022,7 +1022,7 @@ exports[`PLUGIN collect report with axe-plugin NPM package > should run plugin o "icon": "folder-syntax", "packageName": "@code-pushup/axe-plugin", "slug": "axe", - "title": "Axe Accessibility", + "title": "Axe", }, ], } diff --git a/packages/plugin-axe/src/lib/axe-plugin.ts b/packages/plugin-axe/src/lib/axe-plugin.ts index 66e61f2d0..bc390087e 100644 --- a/packages/plugin-axe/src/lib/axe-plugin.ts +++ b/packages/plugin-axe/src/lib/axe-plugin.ts @@ -6,8 +6,8 @@ import { } from '@code-pushup/models'; import { normalizeUrlInput } from '@code-pushup/utils'; import { type AxePluginOptions, axePluginOptionsSchema } from './config.js'; -import { AXE_PLUGIN_SLUG } from './constants.js'; -import { processAuditsAndGroups } from './processing.js'; +import { AXE_PLUGIN_SLUG, AXE_PLUGIN_TITLE } from './constants.js'; +import { processAuditsAndGroups } from './meta/processing.js'; import { createRunnerFunction } from './runner/runner.js'; /** @@ -40,13 +40,13 @@ export function axePlugin( return { slug: AXE_PLUGIN_SLUG, - packageName: packageJson.name, - version: packageJson.version, - title: 'Axe Accessibility', + title: AXE_PLUGIN_TITLE, icon: 'folder-syntax', description: 'Official Code PushUp Axe plugin for automated accessibility testing', docsUrl: 'https://www.npmjs.com/package/@code-pushup/axe-plugin', + packageName: packageJson.name, + version: packageJson.version, audits, groups, runner: createRunnerFunction(normalizedUrls, ruleIds, timeout), diff --git a/packages/plugin-axe/src/lib/constants.ts b/packages/plugin-axe/src/lib/constants.ts index ed6487a10..e5602e51a 100644 --- a/packages/plugin-axe/src/lib/constants.ts +++ b/packages/plugin-axe/src/lib/constants.ts @@ -1,5 +1,15 @@ +import type { AxePreset } from './config.js'; + export const AXE_PLUGIN_SLUG = 'axe'; +export const AXE_PLUGIN_TITLE = 'Axe'; export const AXE_DEFAULT_PRESET = 'wcag21aa'; export const DEFAULT_TIMEOUT_MS = 30_000; + +export const AXE_PRESET_NAMES: Record = { + wcag21aa: 'WCAG 2.1 AA', + wcag22aa: 'WCAG 2.2 AA', + 'best-practice': 'Best practices', + all: 'All', +}; diff --git a/packages/plugin-axe/src/lib/meta/format.ts b/packages/plugin-axe/src/lib/meta/format.ts new file mode 100644 index 000000000..6c8f3d3f9 --- /dev/null +++ b/packages/plugin-axe/src/lib/meta/format.ts @@ -0,0 +1,4 @@ +import { pluginMetaLogFormatter } from '@code-pushup/utils'; +import { AXE_PLUGIN_TITLE } from '../constants.js'; + +export const formatMetaLog = pluginMetaLogFormatter(AXE_PLUGIN_TITLE); diff --git a/packages/plugin-axe/src/lib/meta/processing.ts b/packages/plugin-axe/src/lib/meta/processing.ts new file mode 100644 index 000000000..7e4ddff53 --- /dev/null +++ b/packages/plugin-axe/src/lib/meta/processing.ts @@ -0,0 +1,55 @@ +import ansis from 'ansis'; +import type { Audit, Group } from '@code-pushup/models'; +import { + expandAuditsForUrls, + expandGroupsForUrls, + logger, + pluralizeToken, + shouldExpandForUrls, +} from '@code-pushup/utils'; +import type { AxePreset } from '../config.js'; +import { AXE_PRESET_NAMES } from '../constants.js'; +import { formatMetaLog } from './format.js'; +import { + loadAxeRules, + transformRulesToAudits, + transformRulesToGroups, +} from './transform.js'; + +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); + + logger.info( + formatMetaLog( + `Loaded ${pluralizeToken('Axe rule', rules.length)} for ${ansis.bold(AXE_PRESET_NAMES[preset])} preset, mapped to audits`, + ), + ); + logger.info( + formatMetaLog( + `Created ${pluralizeToken('group', groups.length)} from Axe categories`, + ), + ); + + if (!shouldExpandForUrls(urls.length)) { + return { audits, groups, ruleIds }; + } + + const expandedAudits = expandAuditsForUrls(audits, urls); + const expandedGroups = expandGroupsForUrls(groups, urls); + logger.info( + formatMetaLog( + `Expanded audits (${audits.length} → ${expandedAudits.length}) and groups (${groups.length} → ${expandedGroups.length}) for ${pluralizeToken('URL', urls.length)}`, + ), + ); + return { audits: expandedAudits, groups: expandedGroups, ruleIds }; +} diff --git a/packages/plugin-axe/src/lib/processing.unit.test.ts b/packages/plugin-axe/src/lib/meta/processing.unit.test.ts similarity index 100% rename from packages/plugin-axe/src/lib/processing.unit.test.ts rename to packages/plugin-axe/src/lib/meta/processing.unit.test.ts diff --git a/packages/plugin-axe/src/lib/processing.ts b/packages/plugin-axe/src/lib/processing.ts deleted file mode 100644 index 0a8d0ba4a..000000000 --- a/packages/plugin-axe/src/lib/processing.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Audit, Group } from '@code-pushup/models'; -import { - expandAuditsForUrls, - expandGroupsForUrls, - shouldExpandForUrls, -} from '@code-pushup/utils'; -import type { AxePreset } from './config.js'; -import { - loadAxeRules, - transformRulesToAudits, - transformRulesToGroups, -} from './meta/transform.js'; - -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); - - 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/runner/run-axe.ts b/packages/plugin-axe/src/lib/runner/run-axe.ts index 7aba6ea82..3e53fe3e0 100644 --- a/packages/plugin-axe/src/lib/runner/run-axe.ts +++ b/packages/plugin-axe/src/lib/runner/run-axe.ts @@ -1,13 +1,16 @@ import { AxeBuilder } from '@axe-core/playwright'; +import ansis from 'ansis'; +import type { AxeResults } from 'axe-core'; import { createRequire } from 'node:module'; import path from 'node:path'; -import { type Browser, chromium } from 'playwright-core'; +import { type Browser, type Page, chromium } from 'playwright-core'; import type { AuditOutputs } from '@code-pushup/models'; import { executeProcess, + formatAsciiTable, + indentLines, logger, pluralizeToken, - stringifyError, } from '@code-pushup/utils'; import { toAuditOutputs } from './transform.js'; @@ -16,62 +19,113 @@ let browser: Browser | undefined; let browserChecked = false; /* eslint-enable functional/no-let */ -export async function runAxeForUrl( - url: string, - ruleIds: string[], - timeout: number, -): Promise { - try { - if (!browser) { - await ensureBrowserInstalled(); - logger.debug('Launching Chromium browser...'); - browser = await chromium.launch({ headless: true }); - } +export type AxeUrlArgs = { + url: string; + urlIndex: number; + urlsCount: number; + ruleIds: string[]; + timeout: number; +}; + +export type AxeUrlResult = { + url: string; + axeResults: AxeResults; + auditOutputs: AuditOutputs; +}; + +export async function runAxeForUrl(args: AxeUrlArgs): Promise { + const { url, urlIndex, urlsCount } = args; + + if (!browser) { + await ensureBrowserInstalled(); + browser = await logger.task('Launching Chromium browser', async () => ({ + message: 'Launched Chromium browser', + result: await chromium.launch({ headless: true }), + })); + } + + const prefix = ansis.gray(`[${urlIndex + 1}/${urlsCount}]`); - const context = await browser.newContext(); + return await logger.task(`${prefix} Analyzing URL ${url}`, async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const context = await browser!.newContext(); try { const page = await context.newPage(); try { - await page.goto(url, { - waitUntil: 'networkidle', - timeout, - }); - - const axeBuilder = new AxeBuilder({ page }); - - // Use withRules() to include experimental/deprecated rules - if (ruleIds.length > 0) { - axeBuilder.withRules(ruleIds); - } - - const results = await axeBuilder.analyze(); - - const incompleteCount = results.incomplete.length; - - if (incompleteCount > 0) { - logger.warn( - `Axe returned ${pluralizeToken('incomplete result', incompleteCount)} for ${url}`, - ); - } - - return toAuditOutputs(results, url); + const axeResults = await runAxeForPage(page, args); + const auditOutputs = toAuditOutputs(axeResults, url); + return { + message: `${prefix} Analyzed URL ${url}`, + result: { url, axeResults, auditOutputs }, + }; } finally { await page.close(); } } finally { await context.close(); } - } catch (error) { - logger.error(`Axe execution failed for ${url}: ${stringifyError(error)}`); - throw error; + }); +} + +async function runAxeForPage( + page: Page, + { url, ruleIds, timeout }: AxeUrlArgs, +): Promise { + await page.goto(url, { + waitUntil: 'networkidle', + timeout, + }); + + const axeBuilder = new AxeBuilder({ page }); + + // Use withRules() to include experimental/deprecated rules + if (ruleIds.length > 0) { + axeBuilder.withRules(ruleIds); + } + + const results = await axeBuilder.analyze(); + + logger.debug( + formatAsciiTable({ + columns: ['left', 'right'], + rows: [ + ['Passes', results.passes.length], + ['Violations', results.violations.length], + ['Incomplete', results.incomplete.length], + ['Inapplicable', results.inapplicable.length], + ], + }), + ); + + if (results.incomplete.length > 0) { + logger.warn( + `Axe returned ${pluralizeToken('incomplete result', results.incomplete.length)}`, + ); + logger.debug( + results.incomplete + .flatMap(res => [ + `• ${res.id}`, + indentLines( + res.nodes + .flatMap(node => [...node.all, ...node.any]) + .map(check => `- ${check.message}`) + .join('\n'), + 2, + ), + ]) + .join('\n'), + ); } + + return results; } export async function closeBrowser(): Promise { if (browser) { await browser.close(); browser = undefined; + logger.debug('Closed Chromium browser'); } } @@ -86,7 +140,7 @@ async function ensureBrowserInstalled(): Promise { return; } - logger.debug('Checking Chromium browser installation...'); + logger.debug('Checking Chromium browser installation ...'); const require = createRequire(import.meta.url); const pkgPath = require.resolve('playwright-core/package.json'); diff --git a/packages/plugin-axe/src/lib/runner/runner.ts b/packages/plugin-axe/src/lib/runner/runner.ts index 387c13529..d83ad4edf 100644 --- a/packages/plugin-axe/src/lib/runner/runner.ts +++ b/packages/plugin-axe/src/lib/runner/runner.ts @@ -1,70 +1,97 @@ -import type { - AuditOutputs, - RunnerArgs, - RunnerFunction, -} from '@code-pushup/models'; +import type { AuditOutputs, RunnerFunction } from '@code-pushup/models'; import { addIndex, + asyncSequential, + formatAsciiTable, logger, pluralizeToken, shouldExpandForUrls, stringifyError, } from '@code-pushup/utils'; -import { closeBrowser, runAxeForUrl } from './run-axe.js'; +import { + type AxeUrlArgs, + type AxeUrlResult, + closeBrowser, + runAxeForUrl, +} from './run-axe.js'; export function createRunnerFunction( urls: string[], ruleIds: string[], timeout: number, ): RunnerFunction { - return async (_runnerArgs?: RunnerArgs): Promise => { - const urlCount = urls.length; - const isSingleUrl = !shouldExpandForUrls(urlCount); + return async (): Promise => { + const urlsCount = urls.length; logger.info( - `Running Axe accessibility checks for ${pluralizeToken('URL', urlCount)}...`, + `Running Axe accessibility checks for ${pluralizeToken('URL', urlsCount)} ...`, ); try { - const allResults = await urls.reduce(async (prev, url, index) => { - const acc = await prev; - - logger.debug(`Testing URL ${index + 1}/${urlCount}: ${url}`); - - try { - const auditOutputs = await runAxeForUrl(url, ruleIds, timeout); - - 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([])); - - const totalAuditCount = allResults.length; + const results = await asyncSequential( + urls, + async (url, urlIndex): Promise => + runForUrl({ urlsCount, ruleIds, timeout, url, urlIndex }), + ); - if (totalAuditCount === 0) { + const collectedResults = results.filter(res => res != null); + const auditOutputs = collectedResults.flatMap(res => res.auditOutputs); + if (collectedResults.length === 0) { throw new Error( - isSingleUrl - ? 'Axe did not produce any results.' - : 'Axe failed to produce results for all URLs.', + shouldExpandForUrls(urlsCount) + ? 'Axe failed to produce results for all URLs.' + : 'Axe did not produce any results.', ); } - logger.info( - `Completed Axe accessibility checks with ${pluralizeToken('audit', totalAuditCount)}`, - ); + logResultsForAllUrls(collectedResults); - return allResults; + return auditOutputs; } finally { await closeBrowser(); } }; } + +async function runForUrl(args: AxeUrlArgs): Promise { + const { url, urlsCount, urlIndex } = args; + try { + const result = await runAxeForUrl(args); + + if (shouldExpandForUrls(urlsCount)) { + return { + ...result, + auditOutputs: result.auditOutputs.map(audit => ({ + ...audit, + slug: addIndex(audit.slug, urlIndex), + })), + }; + } + + return result; + } catch (error) { + logger.warn(`Axe execution failed for ${url}: ${stringifyError(error)}`); + return null; + } +} + +function logResultsForAllUrls(results: AxeUrlResult[]): void { + logger.info( + formatAsciiTable({ + columns: [ + { key: 'url', label: 'URL', align: 'left' }, + { key: 'passes', label: 'Passes', align: 'right' }, + { key: 'violations', label: 'Violations', align: 'right' }, + { key: 'incomplete', label: 'Incomplete', align: 'right' }, + { key: 'inapplicable', label: 'Inapplicable', align: 'right' }, + ], + rows: results.map(res => ({ + url: res.url, + passes: res.axeResults.passes.length, + violations: res.axeResults.violations.length, + incomplete: res.axeResults.incomplete.length, + inapplicable: res.axeResults.inapplicable.length, + })), + }), + ); +} diff --git a/packages/plugin-axe/src/lib/runner/runner.unit.test.ts b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts index 42341b5e7..62f782a93 100644 --- a/packages/plugin-axe/src/lib/runner/runner.unit.test.ts +++ b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts @@ -1,3 +1,4 @@ +import type { AxeResults, IncompleteResult, Result } from 'axe-core'; 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'; @@ -22,39 +23,59 @@ describe('createRunnerFunction', () => { value: 0, displayValue: 'No violations found', }); + const mockAxeResults = { + passes: [] as Result[], + violations: [] as Result[], + incomplete: [] as IncompleteResult[], + inapplicable: [] as Result[], + } as AxeResults; 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 mockResult: runAxe.AxeUrlResult = { + url: 'https://example.com', + axeResults: mockAxeResults, + auditOutputs: [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('html-has-lang'), + ], + }; + mockRunAxeForUrl.mockResolvedValue(mockResult); const runnerFn = createRunnerFunction(['https://example.com'], [], 30_000); const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); - expect(mockRunAxeForUrl).toHaveBeenCalledWith( - 'https://example.com', - [], - 30_000, - ); + expect(mockRunAxeForUrl).toHaveBeenCalledWith({ + url: 'https://example.com', + urlIndex: 0, + urlsCount: 1, + ruleIds: [], + timeout: 30_000, + }); expect(mockCloseBrowser).toHaveBeenCalled(); - expect(results).toEqual(mockResults); + expect(results).toEqual(mockResult.auditOutputs); }); 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'), - ]; + const mockResult1: runAxe.AxeUrlResult = { + url: 'https://example.com', + axeResults: mockAxeResults, + auditOutputs: [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('html-has-lang'), + ], + }; + const mockResult2: runAxe.AxeUrlResult = { + url: 'https://another-example.org', + axeResults: mockAxeResults, + auditOutputs: [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('color-contrast'), + ], + }; mockRunAxeForUrl - .mockResolvedValueOnce(mockResults1) - .mockResolvedValueOnce(mockResults2); + .mockResolvedValueOnce(mockResult1) + .mockResolvedValueOnce(mockResult2); const runnerFn = createRunnerFunction( ['https://example.com', 'https://another-example.org'], @@ -64,18 +85,20 @@ describe('createRunnerFunction', () => { const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); expect(mockRunAxeForUrl).toHaveBeenCalledTimes(2); - expect(mockRunAxeForUrl).toHaveBeenNthCalledWith( - 1, - 'https://example.com', - [], - 30_000, - ); - expect(mockRunAxeForUrl).toHaveBeenNthCalledWith( - 2, - 'https://another-example.org', - [], - 30_000, - ); + expect(mockRunAxeForUrl).toHaveBeenNthCalledWith(1, { + url: 'https://example.com', + urlIndex: 0, + urlsCount: 2, + ruleIds: [], + timeout: 30_000, + } satisfies runAxe.AxeUrlArgs); + expect(mockRunAxeForUrl).toHaveBeenNthCalledWith(2, { + url: 'https://another-example.org', + urlIndex: 1, + urlsCount: 2, + ruleIds: [], + timeout: 30_000, + } satisfies runAxe.AxeUrlArgs); expect(mockCloseBrowser).toHaveBeenCalled(); expect(results).toBeArrayOfSize(4); @@ -88,11 +111,15 @@ describe('createRunnerFunction', () => { }); it('should run only specified rules when ruleIds filter is provided', async () => { - const mockResults = [ - createMockAuditOutput('image-alt'), - createMockAuditOutput('html-has-lang'), - ]; - mockRunAxeForUrl.mockResolvedValue(mockResults); + const mockResult: runAxe.AxeUrlResult = { + url: 'https://example.com', + axeResults: mockAxeResults, + auditOutputs: [ + createMockAuditOutput('image-alt'), + createMockAuditOutput('html-has-lang'), + ], + }; + mockRunAxeForUrl.mockResolvedValue(mockResult); const ruleIds = ['image-alt', 'html-has-lang']; const runnerFn = createRunnerFunction( @@ -102,20 +129,26 @@ describe('createRunnerFunction', () => { ); const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); - expect(mockRunAxeForUrl).toHaveBeenCalledWith( - 'https://example.com', + expect(mockRunAxeForUrl).toHaveBeenCalledWith({ + url: 'https://example.com', + urlIndex: 0, + urlsCount: 1, ruleIds, - 30_000, - ); - expect(results).toEqual(mockResults); + timeout: 30_000, + }); + expect(results).toEqual(mockResult.auditOutputs); }); it('should continue with other URLs when one fails in multiple URL scenario', async () => { - const mockResults = [createMockAuditOutput('image-alt')]; + const mockResult: runAxe.AxeUrlResult = { + url: 'https://working.com', + axeResults: mockAxeResults, + auditOutputs: [createMockAuditOutput('image-alt')], + }; mockRunAxeForUrl .mockRejectedValueOnce(new Error('Failed to load page')) - .mockResolvedValueOnce(mockResults); + .mockResolvedValueOnce(mockResult); const runnerFn = createRunnerFunction( ['https://broken.com', 'https://working.com'], diff --git a/packages/utils/src/lib/text-formats/ascii/table.ts b/packages/utils/src/lib/text-formats/ascii/table.ts index 0efa9ee7e..cbf01f659 100644 --- a/packages/utils/src/lib/text-formats/ascii/table.ts +++ b/packages/utils/src/lib/text-formats/ascii/table.ts @@ -53,6 +53,8 @@ const BORDERS = { }, }; +const WORD_REGEX = /^[a-zA-Z]+(-[a-zA-Z])?$/; + export function formatAsciiTable( table: Table, options?: AsciiTableOptions, @@ -164,10 +166,13 @@ function wrapText(text: string, width: number | undefined): string { const words = extractWords(text); const longWords = words.filter(word => word.length > width); const replacements = longWords.map(original => { - const parts = original.includes('-') - ? original.split('-') - : partitionString(original, width - 1); - const replacement = parts.join('-\n'); + const isWord = WORD_REGEX.test(original); + const parts = isWord + ? original.includes('-') + ? original.split('-') + : partitionString(original, width - 1) + : partitionString(original, width); + const replacement = parts.join(isWord ? '-\n' : '\n'); return { original, replacement }; }); const textWithSplitLongWords = replacements.reduce(