Skip to content

Commit 09b3a75

Browse files
committed
Add cypress-axe for full-app a11y coverage
Component a11y is covered by Storybook + axe; this hooks `cypress-axe` into the existing Cypress suite to scan the full GraphiQL UI at four checkpoints (initial render, after running a query, with the docs panel open, with the history panel open). `cypress/.a11y-baseline.json` pins today's accepted violations; CI fails on net-new only. A small `writeBaseline` Node task in `cypress.config.ts` lets the spec persist updates — Cypress runs in the browser, so it can't `fs.writeFileSync` directly. Refresh: yarn workspace graphiql test:a11y:update
1 parent befd259 commit 09b3a75

8 files changed

Lines changed: 272 additions & 598 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: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
const baselineKeys = new Set(
50+
baselineEntries.map(v => `${v.id}:${v.nodeCount}`),
51+
);
52+
const newViolations = violations.filter(
53+
v => !baselineKeys.has(`${v.id}:${v.nodes.length}`),
54+
);
55+
if (newViolations.length > 0) {
56+
const summary = newViolations
57+
.map(v => `${v.id} (${v.impact}): ${v.help}`)
58+
.join('\n');
59+
throw new Error(
60+
`New a11y violations at "${checkpoint}":\n${summary}`,
61+
);
62+
}
63+
}
64+
},
65+
// Don't let cypress-axe auto-throw — the callback above is the only
66+
// source of failures (compare-against-baseline in normal mode; capture
67+
// and write in update mode).
68+
true,
69+
);
70+
}
71+
72+
describe('a11y baseline', () => {
73+
beforeEach(() => {
74+
cy.visit('/');
75+
cy.injectAxe();
76+
});
77+
78+
it('initial render has no new violations', () => {
79+
checkOrCapture('initial');
80+
});
81+
82+
it('after running a query has no new violations', () => {
83+
cy.clickExecuteQuery();
84+
// Wait for the response panel to populate before scanning
85+
cy.get('section.result-window').should('not.have.text', '');
86+
cy.injectAxe();
87+
checkOrCapture('post-run');
88+
});
89+
90+
it('with docs panel open has no new violations', () => {
91+
// First sidebar button is the docs explorer toggle (confirmed in docs.cy.ts)
92+
cy.get('.graphiql-sidebar button').eq(0).click();
93+
cy.get('.graphiql-doc-explorer').should('be.visible');
94+
cy.injectAxe();
95+
checkOrCapture('docs-open');
96+
});
97+
98+
it('with history panel open has no new violations', () => {
99+
// history.cy.ts uses this exact selector
100+
cy.get('button[aria-label="Show History"]').click();
101+
cy.get('.graphiql-history').should('be.visible');
102+
cy.injectAxe();
103+
checkOrCapture('history-open');
104+
});
105+
});

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)