@techsio/storybook-a11y-reporter 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +60 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @techsio/storybook-a11y-reporter
|
|
2
|
+
|
|
3
|
+
CLI/CI reporter that captures Storybook a11y results (including APCA) and writes JSON/JUnit summaries.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
1) Configure Storybook test-runner to use this reporter.
|
|
7
|
+
2) Run the test-runner in CI.
|
|
8
|
+
|
|
9
|
+
Example `.storybook/test-runner.ts`:
|
|
10
|
+
```ts
|
|
11
|
+
import { createA11yReporter } from '@techsio/storybook-a11y-reporter';
|
|
12
|
+
|
|
13
|
+
export default createA11yReporter({
|
|
14
|
+
outputDir: 'a11y-report',
|
|
15
|
+
failOnViolations: true,
|
|
16
|
+
writeJUnit: true,
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Run:
|
|
21
|
+
```bash
|
|
22
|
+
storybook test --config-dir .storybook
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Outputs:
|
|
26
|
+
- `a11y-report/report.json`
|
|
27
|
+
- `a11y-report/junit.xml`
|
|
28
|
+
- Console summary
|
|
29
|
+
|
|
30
|
+
## CI / PR reporting (GitHub Actions)
|
|
31
|
+
The reporter fails the run by default when violations are found. You can override with
|
|
32
|
+
`failOnViolations: false` or `A11Y_REPORT_FAIL_ON_VIOLATIONS=false`.
|
|
33
|
+
|
|
34
|
+
Example workflow snippet that posts a PR comment and a check:
|
|
35
|
+
```yaml
|
|
36
|
+
jobs:
|
|
37
|
+
a11y:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
permissions:
|
|
40
|
+
contents: read
|
|
41
|
+
pull-requests: write
|
|
42
|
+
checks: write
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
- uses: pnpm/action-setup@v4
|
|
46
|
+
with:
|
|
47
|
+
version: 10
|
|
48
|
+
- run: pnpm install --frozen-lockfile
|
|
49
|
+
- run: pnpm storybook test --config-dir .storybook
|
|
50
|
+
|
|
51
|
+
- name: Publish check (JUnit)
|
|
52
|
+
uses: dorny/test-reporter@v1
|
|
53
|
+
with:
|
|
54
|
+
name: Storybook A11y
|
|
55
|
+
path: a11y-report/junit.xml
|
|
56
|
+
reporter: java-junit
|
|
57
|
+
|
|
58
|
+
- name: PR comment summary
|
|
59
|
+
run: |
|
|
60
|
+
npx --yes @techsio/storybook-a11y-reporter --input a11y-report/report.json --fail-on-violations false > a11y-summary.md
|
|
61
|
+
- uses: peter-evans/create-or-update-comment@v4
|
|
62
|
+
with:
|
|
63
|
+
issue-number: ${{ github.event.pull_request.number }}
|
|
64
|
+
body-path: a11y-summary.md
|
|
65
|
+
```
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import node_path from "node:path";
|
|
3
|
+
import fs_extra from "fs-extra";
|
|
4
|
+
import "@storybook/test-runner";
|
|
5
|
+
function countViolations(results) {
|
|
6
|
+
if (!results) return 0;
|
|
7
|
+
return Array.isArray(results.violations) ? results.violations.length : 0;
|
|
8
|
+
}
|
|
9
|
+
function countApcaViolations(results) {
|
|
10
|
+
if (!results || !Array.isArray(results.violations)) return 0;
|
|
11
|
+
return results.violations.filter((violation)=>violation?.id === 'apca-contrast').length;
|
|
12
|
+
}
|
|
13
|
+
function formatSummary(entries) {
|
|
14
|
+
const total = entries.length;
|
|
15
|
+
const withViolations = entries.filter((entry)=>countViolations(entry.results) > 0);
|
|
16
|
+
const totalViolations = withViolations.reduce((sum, entry)=>sum + countViolations(entry.results), 0);
|
|
17
|
+
const apcaViolations = withViolations.reduce((sum, entry)=>sum + countApcaViolations(entry.results), 0);
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push('# Storybook A11y Report');
|
|
20
|
+
lines.push('');
|
|
21
|
+
lines.push(`- Total stories: ${total}`);
|
|
22
|
+
lines.push(`- Stories with violations: ${withViolations.length}`);
|
|
23
|
+
lines.push(`- Total violations: ${totalViolations}`);
|
|
24
|
+
lines.push(`- APCA violations: ${apcaViolations}`);
|
|
25
|
+
lines.push('');
|
|
26
|
+
lines.push('| Story | Violations | APCA |');
|
|
27
|
+
lines.push('| --- | --- | --- |');
|
|
28
|
+
for (const entry of entries){
|
|
29
|
+
const violations = countViolations(entry.results);
|
|
30
|
+
const apca = countApcaViolations(entry.results);
|
|
31
|
+
const name = `${entry.title} / ${entry.name}`;
|
|
32
|
+
lines.push(`| ${name} | ${violations} | ${apca} |`);
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
function readReport(filePath = 'a11y-report/report.json') {
|
|
37
|
+
const resolvedPath = node_path.resolve(process.cwd(), filePath);
|
|
38
|
+
return fs_extra.readJSONSync(resolvedPath);
|
|
39
|
+
}
|
|
40
|
+
function printSummary(entries) {
|
|
41
|
+
return formatSummary(entries);
|
|
42
|
+
}
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
const inputIndex = args.indexOf('--input');
|
|
45
|
+
const failIndex = args.indexOf('--fail-on-violations');
|
|
46
|
+
const input = inputIndex >= 0 ? args[inputIndex + 1] : void 0;
|
|
47
|
+
const fail = failIndex >= 0 ? 'false' !== args[failIndex + 1] : true;
|
|
48
|
+
try {
|
|
49
|
+
const entries = readReport(input);
|
|
50
|
+
const summary = printSummary(entries);
|
|
51
|
+
console.log(summary);
|
|
52
|
+
const violations = entries.reduce((sum, entry)=>{
|
|
53
|
+
const count = Array.isArray(entry.results?.violations) ? entry.results.violations.length : 0;
|
|
54
|
+
return sum + count;
|
|
55
|
+
}, 0);
|
|
56
|
+
if (fail && violations > 0) process.exit(1);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(error);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type TestRunnerConfig } from '@storybook/test-runner';
|
|
2
|
+
export interface ReporterOptions {
|
|
3
|
+
outputDir?: string;
|
|
4
|
+
failOnViolations?: boolean;
|
|
5
|
+
writeJUnit?: boolean;
|
|
6
|
+
waitForResultsMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface StoryReportEntry {
|
|
9
|
+
storyId: string;
|
|
10
|
+
title: string;
|
|
11
|
+
name: string;
|
|
12
|
+
url: string;
|
|
13
|
+
parameters?: Record<string, unknown> | null;
|
|
14
|
+
results: any | null;
|
|
15
|
+
}
|
|
16
|
+
export declare function createA11yReporter(options?: ReporterOptions): TestRunnerConfig;
|
|
17
|
+
export declare function readReport(filePath?: string): StoryReportEntry[];
|
|
18
|
+
export declare function printSummary(entries: StoryReportEntry[]): string;
|
|
19
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAEhF,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC5C,OAAO,EAAE,GAAG,GAAG,IAAI,CAAC;CACrB;AAgHD,wBAAgB,kBAAkB,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,gBAAgB,CAkC9E;AAED,wBAAgB,UAAU,CAAC,QAAQ,SAA4B,GAAG,gBAAgB,EAAE,CAGnF;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,MAAM,CAEhE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import node_path from "node:path";
|
|
2
|
+
import fs_extra from "fs-extra";
|
|
3
|
+
import { getStoryContext } from "@storybook/test-runner";
|
|
4
|
+
const DEFAULT_OPTIONS = {
|
|
5
|
+
outputDir: 'a11y-report',
|
|
6
|
+
failOnViolations: true,
|
|
7
|
+
writeJUnit: true,
|
|
8
|
+
waitForResultsMs: 8000
|
|
9
|
+
};
|
|
10
|
+
function resolveOptions(options) {
|
|
11
|
+
const envOutputDir = process.env.A11Y_REPORT_OUTPUT_DIR;
|
|
12
|
+
const envFail = process.env.A11Y_REPORT_FAIL_ON_VIOLATIONS;
|
|
13
|
+
const envJUnit = process.env.A11Y_REPORT_JUNIT;
|
|
14
|
+
const envWait = process.env.A11Y_REPORT_WAIT_MS;
|
|
15
|
+
return {
|
|
16
|
+
...DEFAULT_OPTIONS,
|
|
17
|
+
...options,
|
|
18
|
+
outputDir: envOutputDir || options?.outputDir || DEFAULT_OPTIONS.outputDir,
|
|
19
|
+
failOnViolations: void 0 !== envFail ? 'false' !== envFail : options?.failOnViolations ?? DEFAULT_OPTIONS.failOnViolations,
|
|
20
|
+
writeJUnit: void 0 !== envJUnit ? 'false' !== envJUnit : options?.writeJUnit ?? DEFAULT_OPTIONS.writeJUnit,
|
|
21
|
+
waitForResultsMs: envWait ? Number(envWait) : options?.waitForResultsMs ?? DEFAULT_OPTIONS.waitForResultsMs
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function countViolations(results) {
|
|
25
|
+
if (!results) return 0;
|
|
26
|
+
return Array.isArray(results.violations) ? results.violations.length : 0;
|
|
27
|
+
}
|
|
28
|
+
function countApcaViolations(results) {
|
|
29
|
+
if (!results || !Array.isArray(results.violations)) return 0;
|
|
30
|
+
return results.violations.filter((violation)=>violation?.id === 'apca-contrast').length;
|
|
31
|
+
}
|
|
32
|
+
function escapeXml(value) {
|
|
33
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
34
|
+
}
|
|
35
|
+
function formatSummary(entries) {
|
|
36
|
+
const total = entries.length;
|
|
37
|
+
const withViolations = entries.filter((entry)=>countViolations(entry.results) > 0);
|
|
38
|
+
const totalViolations = withViolations.reduce((sum, entry)=>sum + countViolations(entry.results), 0);
|
|
39
|
+
const apcaViolations = withViolations.reduce((sum, entry)=>sum + countApcaViolations(entry.results), 0);
|
|
40
|
+
const lines = [];
|
|
41
|
+
lines.push('# Storybook A11y Report');
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push(`- Total stories: ${total}`);
|
|
44
|
+
lines.push(`- Stories with violations: ${withViolations.length}`);
|
|
45
|
+
lines.push(`- Total violations: ${totalViolations}`);
|
|
46
|
+
lines.push(`- APCA violations: ${apcaViolations}`);
|
|
47
|
+
lines.push('');
|
|
48
|
+
lines.push('| Story | Violations | APCA |');
|
|
49
|
+
lines.push('| --- | --- | --- |');
|
|
50
|
+
for (const entry of entries){
|
|
51
|
+
const violations = countViolations(entry.results);
|
|
52
|
+
const apca = countApcaViolations(entry.results);
|
|
53
|
+
const name = `${entry.title} / ${entry.name}`;
|
|
54
|
+
lines.push(`| ${name} | ${violations} | ${apca} |`);
|
|
55
|
+
}
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
function formatJUnit(entries) {
|
|
59
|
+
const testcases = entries.map((entry)=>{
|
|
60
|
+
const violations = countViolations(entry.results);
|
|
61
|
+
const name = `${entry.title} / ${entry.name}`;
|
|
62
|
+
const className = entry.title;
|
|
63
|
+
if (violations > 0) {
|
|
64
|
+
const message = `${violations} accessibility violation(s)`;
|
|
65
|
+
return ` <testcase classname="${escapeXml(className)}" name="${escapeXml(name)}">\n <failure message="${escapeXml(message)}" />\n </testcase>`;
|
|
66
|
+
}
|
|
67
|
+
return ` <testcase classname="${escapeXml(className)}" name="${escapeXml(name)}" />`;
|
|
68
|
+
});
|
|
69
|
+
const failures = entries.filter((entry)=>countViolations(entry.results) > 0).length;
|
|
70
|
+
const tests = entries.length;
|
|
71
|
+
return [
|
|
72
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
73
|
+
`<testsuite name="storybook-a11y" tests="${tests}" failures="${failures}">`,
|
|
74
|
+
...testcases,
|
|
75
|
+
'</testsuite>',
|
|
76
|
+
''
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
|
79
|
+
async function writeReports(entries, options) {
|
|
80
|
+
const outputDir = node_path.resolve(process.cwd(), options.outputDir);
|
|
81
|
+
await fs_extra.ensureDir(outputDir);
|
|
82
|
+
const reportPath = node_path.join(outputDir, 'report.json');
|
|
83
|
+
await fs_extra.writeJSON(reportPath, entries, {
|
|
84
|
+
spaces: 2
|
|
85
|
+
});
|
|
86
|
+
const summaryPath = node_path.join(outputDir, 'summary.md');
|
|
87
|
+
await fs_extra.writeFile(summaryPath, formatSummary(entries));
|
|
88
|
+
if (options.writeJUnit) {
|
|
89
|
+
const junitPath = node_path.join(outputDir, 'junit.xml');
|
|
90
|
+
await fs_extra.writeFile(junitPath, formatJUnit(entries));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function createA11yReporter(options) {
|
|
94
|
+
const resolved = resolveOptions(options);
|
|
95
|
+
const entries = [];
|
|
96
|
+
return {
|
|
97
|
+
async postVisit (page, context) {
|
|
98
|
+
const storyId = context.id;
|
|
99
|
+
const storyContext = await getStoryContext(page, context).catch(()=>null);
|
|
100
|
+
await page.waitForFunction((id)=>window.__TECHSIO_A11Y_RESULTS__?.storyId === id, storyId, {
|
|
101
|
+
timeout: resolved.waitForResultsMs
|
|
102
|
+
});
|
|
103
|
+
const pageResults = await page.evaluate(()=>window.__TECHSIO_A11Y_RESULTS__ ?? null);
|
|
104
|
+
const entry = {
|
|
105
|
+
storyId,
|
|
106
|
+
title: context.title,
|
|
107
|
+
name: context.name,
|
|
108
|
+
url: page.url(),
|
|
109
|
+
parameters: storyContext?.parameters?.a11y ?? null,
|
|
110
|
+
results: pageResults?.results ?? null
|
|
111
|
+
};
|
|
112
|
+
entries.push(entry);
|
|
113
|
+
await writeReports(entries, resolved);
|
|
114
|
+
if (resolved.failOnViolations && countViolations(entry.results) > 0) throw new Error(`A11y violations detected in ${context.title} / ${context.name}`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function readReport(filePath = 'a11y-report/report.json') {
|
|
119
|
+
const resolvedPath = node_path.resolve(process.cwd(), filePath);
|
|
120
|
+
return fs_extra.readJSONSync(resolvedPath);
|
|
121
|
+
}
|
|
122
|
+
function printSummary(entries) {
|
|
123
|
+
return formatSummary(entries);
|
|
124
|
+
}
|
|
125
|
+
export { createA11yReporter, printSummary, readReport };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@techsio/storybook-a11y-reporter",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Storybook a11y reporter with APCA support for CI/CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"storybook-a11y-report": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/**/*",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "rslib build",
|
|
21
|
+
"lint": "eslint .",
|
|
22
|
+
"test": "vitest run"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"fs-extra": "^11.3.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/fs-extra": "^11.0.4",
|
|
29
|
+
"@storybook/test-runner": "^0.24.2"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@storybook/test-runner": ">=0.19.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|