Skip to content

Commit 2978ad1

Browse files
authored
chore: integrate cypress-axe for accessibility testing (#11128)
**Note: These changes are intended for use within the monorepo only** These changes are integrating the `cypress-axe` plugin to enable accessibility testing within the UI5 webcomponents project. As part of the integration, a new `CYPRESS_UI5_ACC` environment has been added to control the execution of accessibility tests. When set to `true`, it allows generating an accessibility report otherwise, the tests are skipped. Additionally, a new global function, `ui5AccDescribe`, has been introduced for test files. This function acts as a wrapper around the standard `describe` function, accepting the same parameters. Internally, it determines whether to run or skip tests based on the `CYPRESS_UI5_ACC` value. The wrapper also injects the necessary `axe-core` tool and includes the `ui5CheckA11y` command, which is used for accessibility validation. **Note: If `CYPRESS_UI5_ACC` is set, only accessibility tests will run** Furthermore, the `ui5CheckA11y` command has been added to execute `axe` on mounted content. Related to: #11128
1 parent 1842b2b commit 2978ad1

File tree

12 files changed

+324
-5
lines changed

12 files changed

+324
-5
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ dist
1111
coverage
1212
.nyc_output
1313

14+
# Cypress internal logs
15+
cypress-logs
16+
1417
# scoping feature generated entry points for vite consumption
1518
packages/compat/test/pages/scoped
1619
packages/main/test/pages/scoped

packages/cypress-internal/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"typescript": "^5.6.2",
2020
"rimraf": "^3.0.2",
2121
"cypress-real-events": "^1.12.0",
22-
"@cypress/code-coverage": "^3.13.11"
22+
"@cypress/code-coverage": "^3.13.11",
23+
"axe-core": "^4.10.2",
24+
"cypress-axe": "^1.6.0"
2325
}
2426
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>Cypress Test Report</title>
8+
<style>
9+
body {
10+
font-family: Arial, sans-serif;
11+
line-height: 1.6;
12+
padding: 20px;
13+
}
14+
15+
ul {
16+
list-style-type: none;
17+
padding-left: 20px;
18+
}
19+
20+
.violations ul li:not(:last-child) {
21+
border-bottom: 1px solid black;
22+
}
23+
24+
details {
25+
margin-bottom: 10px;
26+
}
27+
28+
summary {
29+
cursor: pointer;
30+
}
31+
32+
details>ul {
33+
margin-top: 5px;
34+
}
35+
</style>
36+
</head>
37+
38+
<body>
39+
40+
<h1>Cypress Test Report</h1>
41+
42+
<div>
43+
<ul id="test-report">
44+
</ul>
45+
</div>
46+
47+
<script type="module">
48+
const response = await fetch("./acc_logs.json")
49+
const reportData = await response.json();
50+
51+
const printError = (error) => {
52+
return `
53+
<li>
54+
<details class="violations">
55+
<summary>[FAILED] ${error.testTitlePath.join(" > ")}</summary>
56+
<ul>
57+
${error.violations.map(printViolation).join("")}
58+
</ul>
59+
</details>
60+
</li>`
61+
};
62+
63+
const printViolation = (violation) => {
64+
return `
65+
<li>
66+
<b>id:</b> ${violation.id}<br />
67+
<b>impact:</b> ${violation.impact}<br />
68+
<b>description:</b> ${violation.description}
69+
</li>`;
70+
};
71+
72+
const printFileLog = () => {
73+
const testReport = document.querySelector("#test-report");
74+
75+
testReport.innerHTML = reportData.map(fileLog => {
76+
return `
77+
<li>
78+
<details open>
79+
<summary>Test file: ${fileLog.testFile}</summary>
80+
<ul>
81+
${fileLog.errors.map(printError).join("")}
82+
</ul>
83+
</details>
84+
</li>`;
85+
}).join("");
86+
}
87+
88+
printFileLog();
89+
</script>
90+
91+
92+
</body>
93+
94+
</html>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import 'cypress-axe'
2+
import type { AxeResults, ImpactValue } from "axe-core";
3+
import { Options } from "cypress-axe";
4+
5+
type Vialotation = {
6+
id: string,
7+
impact: ImpactValue | undefined,
8+
description: string
9+
nodes: number,
10+
}
11+
12+
type TestVialotation = {
13+
testTitlePath: string[],
14+
violations: Vialotation[]
15+
}
16+
17+
type TestReport = {
18+
testFile: string,
19+
errors: TestVialotation[]
20+
}
21+
22+
function checkA11TerminalLog(violations: typeof AxeResults.violations) {
23+
const violationData = violations.map<Vialotation>(
24+
({ id, impact, description, nodes }) => ({
25+
id,
26+
impact,
27+
description,
28+
nodes: nodes.length
29+
})
30+
)
31+
32+
const report: TestReport = {
33+
testFile: Cypress.spec.relative,
34+
errors: [{
35+
testTitlePath: Cypress.currentTest.titlePath,
36+
violations: violationData,
37+
}]
38+
}
39+
40+
cy.task('ui5ReportA11y', report)
41+
}
42+
43+
declare global {
44+
namespace Cypress {
45+
interface Chainable {
46+
ui5CheckA11y(context?: string | Node | undefined, options?: Options | undefined): Cypress.Chainable<void>;
47+
}
48+
}
49+
}
50+
51+
Cypress.Commands.add("ui5CheckA11y", (context?: string | Node | undefined, options?: Options | undefined) => {
52+
return cy.checkA11y(context || "[data-cy-root]", options, checkA11TerminalLog, false)
53+
})
54+
55+
if (Cypress.env('ui5AccTasksRegistered') === true) {
56+
before(() => {
57+
cy.task('ui5ReportA11yReset', Cypress.spec.relative);
58+
})
59+
}
60+
61+
export type {
62+
TestReport,
63+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { TestReport } from "./support.js";
2+
// @ts-expect-error
3+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
4+
// @ts-expect-error
5+
import * as path from "path";
6+
// @ts-expect-error
7+
import { fileURLToPath } from 'node:url';
8+
9+
// @ts-expect-error
10+
const __filename = fileURLToPath(import.meta.url);
11+
const __dirname = path.dirname(__filename);
12+
13+
const outputPath = path.resolve("./cypress-logs/acc_logs.json");
14+
const outputPathIndex = path.resolve("./cypress-logs/index.html");
15+
16+
const findExistingReport = (reportData: TestReport[], testFile: string) => reportData.find(report => report.testFile === testFile);
17+
18+
const readReportFile = (): TestReport[] => {
19+
try {
20+
return JSON.parse(readFileSync(outputPath, { encoding: "utf-8" })) as TestReport[];
21+
} catch (e) {
22+
return [];
23+
}
24+
}
25+
26+
const log = (currentReport: TestReport) => {
27+
let reportData = readReportFile();
28+
29+
const existingReport = findExistingReport(reportData, currentReport.testFile);
30+
31+
if (existingReport) {
32+
existingReport.errors.push(...currentReport.errors)
33+
} else {
34+
reportData.push(currentReport);
35+
}
36+
37+
reportData.forEach(file => {
38+
const paths = file.errors.map(error => error.testTitlePath.join(" "));
39+
file.errors = file.errors.filter((error, index) => paths.indexOf(error.testTitlePath.join(" ")) === index);
40+
})
41+
42+
saveReportFile(reportData);
43+
}
44+
45+
const saveReportFile = (reportData: TestReport[]) => {
46+
mkdirSync(path.dirname(outputPath), { recursive: true });
47+
48+
writeFileSync(outputPath, JSON.stringify(reportData, undefined, 4));
49+
};
50+
51+
const reset = (testFile: string) => {
52+
let reportData = readReportFile();
53+
const existingReport = findExistingReport(reportData, testFile);
54+
55+
if (existingReport) {
56+
reportData.splice(reportData.indexOf(existingReport), 1)
57+
58+
saveReportFile(reportData);
59+
}
60+
}
61+
62+
const prepare = () => {
63+
const indexTemplate = readFileSync(path.join(__dirname, "index"), { encoding: "utf-8" });
64+
writeFileSync(outputPathIndex, indexTemplate);
65+
66+
saveReportFile([]);
67+
68+
}
69+
70+
function accTask(on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) {
71+
if (config.env.UI5_ACC === true) {
72+
on('before:run', () => {
73+
// Reset the report file when tests are run with the `cypress run` command.
74+
// This event is triggered when running tests with the `cypress open` command (behind an experimental flag).
75+
// `config.isInteractive` helps us determine whether the tests are running in interactive mode (`cypress open`) or non-interactive mode (`cypress run`).
76+
if (!config.isInteractive) {
77+
prepare();
78+
}
79+
});
80+
81+
on('before:browser:launch', () => {
82+
// Reset the report file when tests are run with the `cypress open` command.
83+
// `config.isInteractive` helps us determine whether the tests are running in interactive mode (`cypress open`) or non-interactive mode (`cypress run`).
84+
if (config.isInteractive) {
85+
prepare();
86+
}
87+
});
88+
89+
on('task', {
90+
// Adds the accessibility report for the current test to the spec file logs
91+
ui5ReportA11y(report: TestReport) {
92+
log(report);
93+
94+
return null;
95+
},
96+
97+
// Removes all existing logs for the current test file when the spec file is loaded
98+
ui5ReportA11yReset(testFile: string) {
99+
reset(testFile);
100+
101+
return null;
102+
}
103+
})
104+
105+
config.env.ui5AccTasksRegistered = true
106+
}
107+
108+
return config
109+
}
110+
111+
export default accTask;

packages/cypress-internal/src/commands.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import "cypress-real-events";
22
import '@cypress/code-coverage/support'
3+
import "./acc_report/support.js";
4+
import "./helpers.js"
35

46
const realEventCmdCallback = (originalFn: any, element: any, ...args: any) => {
57
cy.get(element)
@@ -26,4 +28,4 @@ const commands = [
2628

2729
commands.forEach(cmd => {
2830
Cypress.Commands.overwrite(cmd as any, realEventCmdCallback)
29-
});
31+
});

packages/cypress-internal/src/copy.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import path from "path";
33

44
const dirname = import.meta.dirname;
55

6-
await copyFile(path.join(dirname, "./eslint.cjs"), path.join(dirname, "../dist/eslint.cjs"))
6+
const files = [
7+
8+
];
79

8-
console.log("eslint.cjs copied successfully")
10+
await copyFile(path.join(dirname, "./eslint.cjs"), path.join(dirname, "../dist/eslint.cjs"))
11+
console.log("eslint.cjs copied successfully")
12+
await copyFile(path.join(dirname, "./acc_report/index"), path.join(dirname, "../dist/acc_report/index"))
13+
console.log("acc_report/index copied successfully")

packages/cypress-internal/src/cypress.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import { defineConfig } from "cypress";
22
// @ts-ignore
33
import viteConfig from "../../../vite.config.js";
44
import coverageTask from "@cypress/code-coverage/task.js";
5+
import accTask from "./acc_report/task.js";
6+
57

68
export default defineConfig({
79
component: {
810
setupNodeEvents(on, config) {
911
coverageTask(on, config);
12+
accTask(on, config)
1013

1114
return config
1215
},
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
declare global {
2+
function ui5AccDescribe(title: string, fn: (this: Mocha.Suite) => void): Mocha.Suite | void;
3+
}
4+
5+
globalThis.ui5AccDescribe = (title: string, fn: (this: Mocha.Suite) => void): Mocha.Suite | void => {
6+
if (Cypress.env('ui5AccTasksRegistered') === true) {
7+
return describe.only(`${title}`, function (this: Mocha.Suite) {
8+
before(() => {
9+
cy.injectAxe({ axeCorePath: "../../node_modules/axe-core/axe.min.js" });
10+
});
11+
fn.call(this);
12+
});
13+
}
14+
};
15+
16+
export { }

packages/cypress-internal/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"esModuleInterop": true,
1515
"strict": true,
1616
"types": [
17-
"cypress"
17+
"cypress",
18+
"cypress-axe",
1819
]
1920
},
2021
}

0 commit comments

Comments
 (0)