Skip to content

Commit 3810104

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

17 files changed

+712
-14
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/).
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Accessible Page</title>
7+
<style>
8+
body {
9+
font-family: Arial, sans-serif;
10+
padding: 20px;
11+
color: #000;
12+
background-color: #fff;
13+
}
14+
button {
15+
padding: 10px 20px;
16+
font-size: 16px;
17+
background-color: #0066cc;
18+
color: #fff;
19+
border: none;
20+
cursor: pointer;
21+
}
22+
</style>
23+
</head>
24+
<body>
25+
<main>
26+
<h1>Accessible Test Page</h1>
27+
<p>This page follows accessibility best practices.</p>
28+
29+
<img
30+
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23ddd' width='100' height='100'/%3E%3C/svg%3E"
31+
alt="Placeholder image"
32+
/>
33+
34+
<form>
35+
<label for="name">Name:</label>
36+
<input type="text" id="name" name="name" />
37+
38+
<button type="submit">Submit</button>
39+
</form>
40+
</main>
41+
</body>
42+
</html>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Minimal Page</title>
7+
</head>
8+
<body>
9+
<h1>Minimal Test Page</h1>
10+
<p>This is a minimal page with very few elements.</p>
11+
</body>
12+
</html>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Page with Violations</title>
6+
<style>
7+
body {
8+
font-family: Arial, sans-serif;
9+
padding: 20px;
10+
}
11+
</style>
12+
</head>
13+
<body>
14+
<h1>Page with Accessibility Violations</h1>
15+
16+
<!-- Missing alt text -->
17+
<img
18+
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23ddd' width='100' height='100'/%3E%3C/svg%3E"
19+
/>
20+
21+
<!-- Form input without label -->
22+
<form>
23+
<input type="text" name="email" />
24+
<button type="submit">Submit</button>
25+
</form>
26+
27+
<!-- Empty button -->
28+
<button></button>
29+
30+
<!-- Heading skip -->
31+
<h1>First Heading</h1>
32+
<h3>Skipped H2</h3>
33+
34+
<!-- Empty link -->
35+
<a href="#"></a>
36+
</body>
37+
</html>

packages/plugin-axe/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
"access": "public"
4141
},
4242
"type": "module",
43+
"scripts": {
44+
"postinstall": "playwright-core install chromium --with-deps || playwright-core install chromium"
45+
},
4346
"dependencies": {
4447
"@axe-core/playwright": "^4.11.0",
4548
"@code-pushup/models": "0.84.0",

packages/plugin-axe/project.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"targets": {
99
"build": {},
1010
"lint": {},
11-
"unit-test": {}
11+
"unit-test": {},
12+
"int-test": {}
1213
}
1314
}

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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import path from 'node:path';
2+
import { pathToFileURL } from 'node:url';
3+
import { describe, expect, it } from 'vitest';
4+
import { DEFAULT_PERSIST_CONFIG } from '@code-pushup/models';
5+
import { createRunnerFunction } from './runner.js';
6+
7+
describe('createRunnerFunction', () => {
8+
const fixturesDir = path.join(
9+
process.cwd(),
10+
'packages/plugin-axe/mocks/fixtures',
11+
);
12+
13+
const getFixtureUrl = (filename: string): string => {
14+
return pathToFileURL(path.join(fixturesDir, filename)).href;
15+
};
16+
17+
it('should detect violations on page with accessibility issues', async () => {
18+
const urls = [getFixtureUrl('violations.html')];
19+
const runnerFn = createRunnerFunction(urls, []);
20+
const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG });
21+
22+
expect(results.length).toBeGreaterThan(0);
23+
expect(results.filter(({ score }) => score === 0).length).toBeGreaterThan(
24+
0,
25+
);
26+
const imageAltAudit = results.find(({ slug }) => slug === 'image-alt');
27+
expect(imageAltAudit?.score).toBe(0);
28+
expect(imageAltAudit?.value).toBeGreaterThan(0);
29+
});
30+
31+
it('should handle multiple URLs and add index to slugs', async () => {
32+
const urls = [
33+
getFixtureUrl('accessible.html'),
34+
getFixtureUrl('minimal.html'),
35+
];
36+
const runnerFn = createRunnerFunction(urls, []);
37+
const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG });
38+
39+
expect(results.length).toBeGreaterThan(0);
40+
results.forEach(({ slug }) => expect(slug).toMatch(/-[12]$/));
41+
42+
const url1Audits = results.filter(({ slug }) => slug.endsWith('-1'));
43+
const url2Audits = results.filter(({ slug }) => slug.endsWith('-2'));
44+
45+
expect(url1Audits.length).toBeGreaterThan(0);
46+
expect(url2Audits.length).toBeGreaterThan(0);
47+
});
48+
49+
it('should filter audits based on ruleIds', async () => {
50+
const urls = [getFixtureUrl('accessible.html')];
51+
const ruleIds = ['image-alt', 'html-has-lang'];
52+
const runnerFn = createRunnerFunction(urls, ruleIds);
53+
const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG });
54+
55+
expect(results.length).toBeLessThanOrEqual(ruleIds.length);
56+
results.forEach(({ slug }) => expect(ruleIds).toContain(slug));
57+
});
58+
}, 60_000);

0 commit comments

Comments
 (0)