Skip to content

Commit 53054cd

Browse files
v6: Add cypress-axe for full-app a11y coverage (#4258)
## Summary Component a11y is covered by Storybook + axe; this is the full-app counterpart. `cypress-axe` runs axe at four checkpoints during a normal session (initial render, after running a query, with the docs panel open, with the history panel open) and gates PRs against a committed baseline. `cypress/.a11y-baseline.json` pins today's accepted violations — color-contrast in several spots, a couple of nested-interactive cases, link-in-text-block in the docs panel. CI fails on net-new only. The spec lives alongside the existing Cypress suite, so it runs as part of the normal `yarn e2e` flow. `cypress.config.ts` gets a small `writeBaseline` Node task so the spec can persist baseline updates from inside the browser. ## Refresh baseline ``` yarn workspace graphiql test:a11y:update ``` Refs: #4219
1 parent befd259 commit 53054cd

8 files changed

Lines changed: 247 additions & 596 deletions

File tree

packages/graphiql/cypress.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
13
import { defineConfig } from 'cypress';
24

35
const PORT = process.env.CI === 'true' ? 8080 : 5173;
46

57
export default defineConfig({
68
e2e: {
79
baseUrl: `http://localhost:${PORT}`,
10+
setupNodeEvents(on) {
11+
on('task', {
12+
writeBaseline({ filePath, data }: { filePath: string; data: unknown }) {
13+
const abs = path.isAbsolute(filePath)
14+
? filePath
15+
: path.resolve(process.cwd(), filePath);
16+
const dir = path.dirname(abs);
17+
if (!fs.existsSync(dir)) {
18+
fs.mkdirSync(dir, { recursive: true });
19+
}
20+
fs.writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
21+
return null;
22+
},
23+
});
24+
},
825
},
926
video: true,
1027
viewportWidth: 1920,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"initial": [
3+
{
4+
"id": "color-contrast",
5+
"impact": "serious",
6+
"nodeCount": 1
7+
},
8+
{
9+
"id": "nested-interactive",
10+
"impact": "serious",
11+
"nodeCount": 1
12+
}
13+
],
14+
"post-run": [
15+
{
16+
"id": "color-contrast",
17+
"impact": "serious",
18+
"nodeCount": 2
19+
},
20+
{
21+
"id": "nested-interactive",
22+
"impact": "serious",
23+
"nodeCount": 1
24+
}
25+
],
26+
"docs-open": [
27+
{
28+
"id": "color-contrast",
29+
"impact": "serious",
30+
"nodeCount": 21
31+
},
32+
{
33+
"id": "link-in-text-block",
34+
"impact": "serious",
35+
"nodeCount": 1
36+
},
37+
{
38+
"id": "nested-interactive",
39+
"impact": "serious",
40+
"nodeCount": 1
41+
}
42+
],
43+
"history-open": [
44+
{
45+
"id": "color-contrast",
46+
"impact": "serious",
47+
"nodeCount": 1
48+
},
49+
{
50+
"id": "nested-interactive",
51+
"impact": "serious",
52+
"nodeCount": 1
53+
}
54+
]
55+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/// <reference types="cypress" />
2+
/// <reference types="cypress-axe" />
3+
4+
import baseline from '../.a11y-baseline.json';
5+
6+
type ViolationSummary = {
7+
id: string;
8+
impact: string | null;
9+
nodeCount: number;
10+
};
11+
12+
type Baseline = Record<string, ViolationSummary[]>;
13+
14+
const UPDATE_BASELINE = Boolean(Cypress.env('A11Y_UPDATE_BASELINE'));
15+
16+
const RULESET = {
17+
runOnly: {
18+
type: 'tag' as const,
19+
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
20+
},
21+
};
22+
23+
const accumulated: Baseline = {};
24+
25+
function toSummary(v: {
26+
id: string;
27+
impact?: string | null;
28+
nodes: unknown[];
29+
}): ViolationSummary {
30+
return { id: v.id, impact: v.impact ?? null, nodeCount: v.nodes.length };
31+
}
32+
33+
function checkOrCapture(checkpoint: string) {
34+
cy.checkA11y(
35+
undefined,
36+
RULESET,
37+
violations => {
38+
if (UPDATE_BASELINE) {
39+
accumulated[checkpoint] = violations.map(toSummary);
40+
// Task runs in Node; path is relative to the package root.
41+
// cypress.config.ts wires up the writeBaseline task.
42+
cy.task('writeBaseline', {
43+
filePath: 'cypress/.a11y-baseline.json',
44+
data: { ...(baseline as Baseline), ...accumulated },
45+
});
46+
} else {
47+
const baselineEntries: ViolationSummary[] =
48+
(baseline as Baseline)[checkpoint] ?? [];
49+
// Compare by violation id only — node counts drift between local
50+
// (Electron on macOS) and CI (headless Chromium on Linux) for the
51+
// same underlying issues, so they're not a reliable key.
52+
const baselineKeys = new Set(baselineEntries.map(v => v.id));
53+
const newViolations = violations.filter(v => !baselineKeys.has(v.id));
54+
if (newViolations.length > 0) {
55+
const summary = newViolations
56+
.map(v => `${v.id} (${v.impact}): ${v.help}`)
57+
.join('\n');
58+
throw new Error(
59+
`New a11y violations at "${checkpoint}":\n${summary}`,
60+
);
61+
}
62+
}
63+
},
64+
// Don't let cypress-axe auto-throw — the callback above is the only
65+
// source of failures (compare-against-baseline in normal mode; capture
66+
// and write in update mode).
67+
true,
68+
);
69+
}
70+
71+
describe('a11y baseline', () => {
72+
beforeEach(() => {
73+
cy.visit('/');
74+
cy.injectAxe();
75+
});
76+
77+
it('initial render has no new violations', () => {
78+
checkOrCapture('initial');
79+
});
80+
81+
it('after running a query has no new violations', () => {
82+
cy.clickExecuteQuery();
83+
// Wait for the response panel to populate before scanning
84+
cy.get('section.result-window').should('not.have.text', '');
85+
cy.injectAxe();
86+
checkOrCapture('post-run');
87+
});
88+
89+
it('with docs panel open has no new violations', () => {
90+
// First sidebar button is the docs explorer toggle (confirmed in docs.cy.ts)
91+
cy.get('.graphiql-sidebar button').eq(0).click();
92+
cy.get('.graphiql-doc-explorer').should('be.visible');
93+
cy.injectAxe();
94+
checkOrCapture('docs-open');
95+
});
96+
97+
it('with history panel open has no new violations', () => {
98+
// history.cy.ts uses this exact selector
99+
cy.get('button[aria-label="Show History"]').click();
100+
cy.get('.graphiql-history').should('be.visible');
101+
cy.injectAxe();
102+
checkOrCapture('history-open');
103+
});
104+
});

packages/graphiql/cypress/support/e2e.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
/// <reference types="cypress" />
1616

1717
import './commands';
18+
import 'cypress-axe';

packages/graphiql/cypress/tsconfig.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
"lib": ["es2021", "dom"],
55
"types": ["cypress", "node"],
66
"strictNullChecks": true,
7-
"strict": true
7+
"strict": true,
8+
"resolveJsonModule": true,
9+
"esModuleInterop": true
810
},
911
"include": ["**/*.ts"]
1012
}

packages/graphiql/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"cypress-open": "cypress open --browser electron",
4747
"dev": "concurrently 'cross-env PORT=8080 node test/e2e-server' vite",
4848
"e2e": "yarn e2e-server 'cypress run'",
49+
"test:a11y:update": "CYPRESS_A11Y_UPDATE_BASELINE=1 yarn e2e-server 'cypress run --spec cypress/e2e/a11y.cy.ts'",
4950
"e2e-server": "start-server-and-test 'cross-env PORT=8080 node test/e2e-server' 'http-get://localhost:8080/graphql?query={test { id }}'",
5051
"test": "vitest run"
5152
},
@@ -67,9 +68,11 @@
6768
"@testing-library/react": "^16.3.0",
6869
"@vitejs/plugin-react": "^4.4.1",
6970
"@vitest/web-worker": "^4.1.6",
71+
"axe-core": "^4",
7072
"babel-plugin-react-compiler": "19.1.0-rc.1",
7173
"cross-env": "^7.0.2",
7274
"cypress": "^13.13.2",
75+
"cypress-axe": "^1",
7376
"graphql": "^16.11.0",
7477
"lightningcss": "^1.29.3",
7578
"react": "^19.1.0",

resources/custom-words.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ roadmap
189189
roboto
190190
rodionov
191191
rohit
192+
ruleset
192193
runmode
193194
runtimes
194195
saihaj
@@ -242,6 +243,7 @@ vitejs
242243
vitest
243244
vizag
244245
vsix
246+
wcag
245247
webp
246248
websockets
247249
wgutils

0 commit comments

Comments
 (0)