Skip to content

Commit 1281873

Browse files
committed
feat(plugin-axe): implement runner with Playwright/Axe integration
feat(plugin-axe): implement runner with Playwright/Axe integration
1 parent d9d0a6e commit 1281873

File tree

11 files changed

+689
-11
lines changed

11 files changed

+689
-11
lines changed

packages/plugin-axe/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# plugin-axe
2+
3+
This library was generated with [Nx](https://nx.dev).
4+
5+
## Running unit tests
6+
7+
Run `nx test plugin-axe` to execute the unit tests via [Vitest](https://vitest.dev/).

packages/plugin-axe/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export * from './lib/axe-plugin.js';
1+
import { axePlugin } from './lib/axe-plugin.js';
2+
3+
export default axePlugin;
Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,47 @@
1-
export function axePlugin(): string {
2-
return 'plugin-axe';
1+
import { createRequire } from 'node:module';
2+
import type { PluginConfig, PluginUrls } from '@code-pushup/models';
3+
import { normalizeUrlInput } from '@code-pushup/utils';
4+
import type { AxePluginOptions } from './config';
5+
import { AXE_DEFAULT_PRESET, AXE_PLUGIN_SLUG } from './constants';
6+
import { processAuditsAndGroups } from './processing';
7+
import { createRunnerFunction } from './runner/runner';
8+
9+
/**
10+
* Code PushUp plugin for accessibility testing using axe-core.
11+
*
12+
* @public
13+
* @param urls - {@link PluginUrls} URL(s) to test
14+
* @param options - {@link AxePluginOptions} Plugin options
15+
* @returns Plugin configuration
16+
*/
17+
export function axePlugin(
18+
urls: PluginUrls,
19+
options?: AxePluginOptions,
20+
): PluginConfig {
21+
const scoreTargets = options?.scoreTargets;
22+
const preset = options?.preset ?? AXE_DEFAULT_PRESET;
23+
24+
const { urls: normalizedUrls, context } = normalizeUrlInput(urls);
25+
26+
const { audits, groups, ruleIds } = processAuditsAndGroups(
27+
normalizedUrls,
28+
preset,
29+
);
30+
31+
const packageJson = createRequire(import.meta.url)(
32+
'../../package.json',
33+
) as typeof import('../../package.json');
34+
35+
return {
36+
slug: AXE_PLUGIN_SLUG,
37+
packageName: packageJson.name,
38+
version: packageJson.version,
39+
title: 'Axe Accessibility',
40+
icon: 'folder-syntax',
41+
audits,
42+
groups,
43+
runner: createRunnerFunction(normalizedUrls, ruleIds),
44+
context,
45+
...(scoreTargets && { scoreTargets }),
46+
};
347
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import AxeBuilder from '@axe-core/playwright';
2+
import { type Browser, chromium } from 'playwright-core';
3+
import type { AuditOutputs } from '@code-pushup/models';
4+
import { logger, stringifyError } from '@code-pushup/utils';
5+
import { toAuditOutputs } from './transform.js';
6+
7+
let browser: Browser | undefined;
8+
9+
export async function runAxeForUrl(
10+
url: string,
11+
ruleIds: string[],
12+
): Promise<AuditOutputs> {
13+
try {
14+
if (!browser) {
15+
logger.debug('Launching Chromium browser...');
16+
browser = await chromium.launch({ headless: true });
17+
}
18+
19+
const context = await browser.newContext();
20+
21+
try {
22+
const page = await context.newPage();
23+
try {
24+
await page.goto(url, {
25+
waitUntil: 'networkidle',
26+
timeout: 30_000,
27+
});
28+
29+
const axeBuilder = new AxeBuilder({ page });
30+
31+
// Use withRules() to include experimental/deprecated rules
32+
if (ruleIds.length > 0) {
33+
axeBuilder.withRules(ruleIds);
34+
}
35+
36+
const results = await axeBuilder.analyze();
37+
38+
if (results.incomplete.length > 0) {
39+
logger.warn(
40+
`Axe returned ${results.incomplete.length} incomplete result(s) for ${url}`,
41+
);
42+
}
43+
44+
return toAuditOutputs(results, url);
45+
} finally {
46+
await page.close();
47+
}
48+
} finally {
49+
await context.close();
50+
}
51+
} catch (error) {
52+
logger.error(`Axe execution failed for ${url}: ${stringifyError(error)}`);
53+
throw error;
54+
}
55+
}
56+
57+
export async function closeBrowser(): Promise<void> {
58+
if (browser) {
59+
await browser.close();
60+
browser = undefined;
61+
}
62+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type {
2+
AuditOutputs,
3+
RunnerArgs,
4+
RunnerFunction,
5+
} from '@code-pushup/models';
6+
import {
7+
addIndex,
8+
logger,
9+
shouldExpandForUrls,
10+
stringifyError,
11+
} from '@code-pushup/utils';
12+
import { closeBrowser, runAxeForUrl } from './run-axe.js';
13+
14+
export function createRunnerFunction(
15+
urls: string[],
16+
ruleIds: string[],
17+
): RunnerFunction {
18+
return async (_runnerArgs?: RunnerArgs): Promise<AuditOutputs> => {
19+
const isSingleUrl = !shouldExpandForUrls(urls.length);
20+
21+
logger.info(
22+
`Running Axe accessibility checks for ${urls.length} URL(s)...`,
23+
);
24+
25+
try {
26+
const allResults = await urls.reduce(async (prev, url, index) => {
27+
const acc = await prev;
28+
29+
logger.debug(`Testing URL ${index + 1}/${urls.length}: ${url}`);
30+
31+
try {
32+
const auditOutputs = await runAxeForUrl(url, ruleIds);
33+
34+
const processedOutputs = isSingleUrl
35+
? auditOutputs
36+
: auditOutputs.map(audit => ({
37+
...audit,
38+
slug: addIndex(audit.slug, index),
39+
}));
40+
41+
return [...acc, ...processedOutputs];
42+
} catch (error) {
43+
logger.warn(stringifyError(error));
44+
return acc;
45+
}
46+
}, Promise.resolve<AuditOutputs>([]));
47+
48+
if (allResults.length === 0) {
49+
throw new Error(
50+
isSingleUrl
51+
? 'Axe did not produce any results.'
52+
: 'Axe failed to produce results for all URLs.',
53+
);
54+
}
55+
56+
logger.info(
57+
`Completed Axe accessibility checks with ${allResults.length} audit(s)`,
58+
);
59+
60+
return allResults;
61+
} finally {
62+
await closeBrowser();
63+
}
64+
};
65+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { type AuditOutput, DEFAULT_PERSIST_CONFIG } from '@code-pushup/models';
3+
import * as runAxe from './run-axe.js';
4+
import { createRunnerFunction } from './runner.js';
5+
6+
vi.mock('./run-axe.js', () => ({
7+
runAxeForUrl: vi.fn(),
8+
closeBrowser: vi.fn(),
9+
}));
10+
11+
describe('createRunnerFunction', () => {
12+
const mockRunAxeForUrl = vi.mocked(runAxe.runAxeForUrl);
13+
const mockCloseBrowser = vi.mocked(runAxe.closeBrowser);
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
});
18+
19+
const createMockAuditOutput = (slug: string, score = 1): AuditOutput => ({
20+
slug,
21+
score,
22+
value: 0,
23+
displayValue: 'No violations found',
24+
});
25+
26+
it('should handle single URL without adding index to audit slugs', async () => {
27+
const mockResults = [
28+
createMockAuditOutput('image-alt'),
29+
createMockAuditOutput('html-has-lang'),
30+
];
31+
mockRunAxeForUrl.mockResolvedValue(mockResults);
32+
33+
const runnerFn = createRunnerFunction(['https://example.com'], []);
34+
const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG });
35+
36+
expect(mockRunAxeForUrl).toHaveBeenCalledWith('https://example.com', []);
37+
expect(mockCloseBrowser).toHaveBeenCalled();
38+
expect(results).toEqual(mockResults);
39+
});
40+
41+
it('should handle multiple URLs and add index to audit slugs', async () => {
42+
const mockResults1 = [
43+
createMockAuditOutput('image-alt'),
44+
createMockAuditOutput('html-has-lang'),
45+
];
46+
const mockResults2 = [
47+
createMockAuditOutput('image-alt'),
48+
createMockAuditOutput('color-contrast'),
49+
];
50+
51+
mockRunAxeForUrl
52+
.mockResolvedValueOnce(mockResults1)
53+
.mockResolvedValueOnce(mockResults2);
54+
55+
const runnerFn = createRunnerFunction(
56+
['https://example.com', 'https://another-example.org'],
57+
[],
58+
);
59+
const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG });
60+
61+
expect(mockRunAxeForUrl).toHaveBeenCalledTimes(2);
62+
expect(mockRunAxeForUrl).toHaveBeenNthCalledWith(
63+
1,
64+
'https://example.com',
65+
[],
66+
);
67+
expect(mockRunAxeForUrl).toHaveBeenNthCalledWith(
68+
2,
69+
'https://another-example.org',
70+
[],
71+
);
72+
expect(mockCloseBrowser).toHaveBeenCalled();
73+
74+
expect(results).toHaveLength(4);
75+
expect(results.map(({ slug }) => slug)).toEqual([
76+
'image-alt-1',
77+
'html-has-lang-1',
78+
'image-alt-2',
79+
'color-contrast-2',
80+
]);
81+
});
82+
83+
it('should pass ruleIds to runAxeForUrl', async () => {
84+
const mockResults = [
85+
createMockAuditOutput('image-alt'),
86+
createMockAuditOutput('html-has-lang'),
87+
];
88+
mockRunAxeForUrl.mockResolvedValue(mockResults);
89+
90+
const ruleIds = ['image-alt', 'html-has-lang'];
91+
const runnerFn = createRunnerFunction(['https://example.com'], ruleIds);
92+
await runnerFn({ persist: DEFAULT_PERSIST_CONFIG });
93+
94+
expect(mockRunAxeForUrl).toHaveBeenCalledWith(
95+
'https://example.com',
96+
ruleIds,
97+
);
98+
});
99+
100+
it('should continue with other URLs when one fails in multiple URL scenario', async () => {
101+
const mockResults = [createMockAuditOutput('image-alt')];
102+
103+
mockRunAxeForUrl
104+
.mockRejectedValueOnce(new Error('Failed to load page'))
105+
.mockResolvedValueOnce(mockResults);
106+
107+
const runnerFn = createRunnerFunction(
108+
['https://broken.com', 'https://working.com'],
109+
[],
110+
);
111+
const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG });
112+
113+
expect(mockRunAxeForUrl).toHaveBeenCalledTimes(2);
114+
expect(mockCloseBrowser).toHaveBeenCalled();
115+
expect(results).toHaveLength(1);
116+
expect(results[0]?.slug).toBe('image-alt-2');
117+
});
118+
119+
it('should throw error if all URLs fail in multiple URL scenario', async () => {
120+
mockRunAxeForUrl.mockRejectedValue(new Error('Failed to load page'));
121+
122+
const runnerFn = createRunnerFunction(
123+
['https://example.com', 'https://another-example.com'],
124+
[],
125+
);
126+
127+
await expect(runnerFn({ persist: DEFAULT_PERSIST_CONFIG })).rejects.toThrow(
128+
'Axe failed to produce results for all URLs.',
129+
);
130+
});
131+
132+
it('should throw error when single URL fails', async () => {
133+
mockRunAxeForUrl.mockRejectedValue(new Error('Failed to load page'));
134+
135+
const runnerFn = createRunnerFunction(['https://example.com'], []);
136+
137+
await expect(runnerFn({ persist: DEFAULT_PERSIST_CONFIG })).rejects.toThrow(
138+
'Axe did not produce any results.',
139+
);
140+
});
141+
});

0 commit comments

Comments
 (0)