Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
}
Expand Down
10 changes: 5 additions & 5 deletions packages/plugin-axe/src/lib/axe-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions packages/plugin-axe/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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<AxePreset, string> = {
wcag21aa: 'WCAG 2.1 AA',
wcag22aa: 'WCAG 2.2 AA',
'best-practice': 'Best practices',
all: 'All',
};
4 changes: 4 additions & 0 deletions packages/plugin-axe/src/lib/meta/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { pluginMetaLogFormatter } from '@code-pushup/utils';
import { AXE_PLUGIN_TITLE } from '../constants.js';

export const formatMetaLog = pluginMetaLogFormatter(AXE_PLUGIN_TITLE);
55 changes: 55 additions & 0 deletions packages/plugin-axe/src/lib/meta/processing.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
36 changes: 0 additions & 36 deletions packages/plugin-axe/src/lib/processing.ts

This file was deleted.

136 changes: 95 additions & 41 deletions packages/plugin-axe/src/lib/runner/run-axe.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<AuditOutputs> {
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<AxeUrlResult> {
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<AxeResults> {
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<void> {
if (browser) {
await browser.close();
browser = undefined;
logger.debug('Closed Chromium browser');
}
}

Expand All @@ -86,7 +140,7 @@ async function ensureBrowserInstalled(): Promise<void> {
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');
Expand Down
Loading