Skip to content

Commit bf4ec17

Browse files
balzssclaude
andcommitted
feat(ui-scripts): surface axe a11y violations in the visual-diff report
The regression suite already ran axe per page and theme, but the violations were only printed to the Cypress log and discarded. Persist them and render them in the report so designers can triage accessibility issues alongside the pixels. - regression-test: a new recordA11y Cypress task writes cypress/a11y.json keyed by "<slug>-<theme>" (mirroring meta.json); the spec records each violation's rule id, impact, help text, helpUrl, and offending node selector/summary. - ui-scripts visual-diff: a new --a11y flag reads that file and adds a per-row "a11y" badge, an "A11y" filter chip, a header count, and a collapsible list of violations (rule linked to its axe helpUrl, impact, selector, contrast summary) per screenshot. a11yPages/a11yViolations are added to summary.json. - visual-regression.yml passes --a11y cypress/a11y.json to the diff step. Additive: without --a11y the report is unchanged. Unit-tested esc() and a11yFor(), and verified end to end against a real suite run (15 flagged pages). Refs: INSTUI-5137 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f635ee3 commit bf4ec17

6 files changed

Lines changed: 250 additions & 15 deletions

File tree

.github/workflows/visual-regression.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ jobs:
8989
--pr-number ${{ github.event.pull_request.number }}
9090
--pr-url ${{ github.event.pull_request.html_url }}
9191
--meta cypress/meta.json
92+
--a11y cypress/a11y.json
9293
--source-base-url https://github.com/${{ github.repository }}/blob/${{ github.head_ref }}/regression-test/src/app
9394
--facets canvas,light,dark
9495
--app-path app

packages/ui-scripts/lib/__node_tests__/visual-diff.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import {
2929
indexByName,
3030
sourceLinkFor,
3131
appUrlFor,
32-
dilateMask
32+
dilateMask,
33+
esc,
34+
a11yFor
3335
} from '../commands/visual-diff.ts'
3436

3537
// Build a w*h changed-mask with the given filled rectangles set to 1.
@@ -259,3 +261,73 @@ describe('dilateMask', () => {
259261
expect(countSet(out)).toBe(countSet(src))
260262
})
261263
})
264+
265+
describe('esc', () => {
266+
it('escapes HTML-significant characters', () => {
267+
expect(esc('<span class="x">a & b</span>')).toBe(
268+
'&lt;span class=&quot;x&quot;&gt;a &amp; b&lt;/span&gt;'
269+
)
270+
})
271+
272+
it('leaves plain text untouched', () => {
273+
expect(esc('color-contrast')).toBe('color-contrast')
274+
})
275+
})
276+
277+
describe('a11yFor', () => {
278+
const a11y = {
279+
'button-dark': [
280+
{
281+
id: 'color-contrast',
282+
impact: 'serious',
283+
help: 'Elements must meet minimum color contrast ratio thresholds',
284+
helpUrl: 'https://dequeuniversity.com/rules/axe/color-contrast',
285+
nodes: [
286+
{
287+
target: '.css-x-baseButton__children',
288+
html: '<span>primary-inverse</span>',
289+
summary: 'Expected 4.5:1 but got 1.13'
290+
}
291+
]
292+
}
293+
]
294+
}
295+
296+
it('returns empty output and count 0 when there are no violations', () => {
297+
expect(a11yFor('alert-dark.png', a11y)).toEqual({
298+
badge: '',
299+
details: '',
300+
count: 0
301+
})
302+
expect(a11yFor('button-dark.png', null)).toEqual({
303+
badge: '',
304+
details: '',
305+
count: 0
306+
})
307+
})
308+
309+
it('builds a badge and details keyed by slug (name minus .png)', () => {
310+
const { badge, details, count } = a11yFor('button-dark.png', a11y)
311+
expect(count).toBe(1)
312+
expect(badge).toContain('class="pill a11y"')
313+
expect(badge).toContain('⚠ 1 a11y')
314+
// rule id links to the axe helpUrl, impact and selector are rendered
315+
expect(details).toContain(
316+
'href="https://dequeuniversity.com/rules/axe/color-contrast"'
317+
)
318+
expect(details).toContain('<code>color-contrast</code>')
319+
expect(details).toContain('a11y-impact serious')
320+
expect(details).toContain('.css-x-baseButton__children')
321+
expect(details).toContain('Expected 4.5:1 but got 1.13')
322+
})
323+
324+
it('pluralizes the violation count', () => {
325+
const many = {
326+
'x-dark': [...a11y['button-dark'], ...a11y['button-dark']]
327+
}
328+
expect(a11yFor('x-dark.png', many).badge).toContain('⚠ 2 a11y')
329+
expect(a11yFor('x-dark.png', many).details).toContain(
330+
'2 accessibility violations'
331+
)
332+
})
333+
})

packages/ui-scripts/lib/commands/visual-diff.ts

Lines changed: 133 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,33 @@ type Args = {
5555
sourceBaseUrl?: string
5656
facets?: string
5757
appPath?: string
58+
a11y?: string
5859
}
5960

6061
type Meta = Record<string, string>
6162

63+
// Accessibility violations captured by the spec's axe run, keyed by screenshot
64+
// slug (name minus `.png`, e.g. `button-dark`). Written by the `recordA11y`
65+
// Cypress task; see regression-test/cypress.config.ts.
66+
type A11yNode = { target: string; html: string; summary: string }
67+
type A11yViolation = {
68+
id: string
69+
impact: string | null
70+
help: string
71+
helpUrl: string
72+
nodes: A11yNode[]
73+
}
74+
type A11y = Record<string, A11yViolation[]>
75+
76+
/** @internal — exported only for tests; not part of the package's public API. */
77+
export function esc(s: string): string {
78+
return s
79+
.replace(/&/g, '&amp;')
80+
.replace(/</g, '&lt;')
81+
.replace(/>/g, '&gt;')
82+
.replace(/"/g, '&quot;')
83+
}
84+
6285
/** @internal — exported only for tests; not part of the package's public API. */
6386
export function sourceLinkFor(
6487
name: string,
@@ -260,12 +283,62 @@ export function thumb(mode: string, name: string): string {
260283
return `<img loading="lazy" src="${mode}/${name}" data-name="${name}" data-mode="${mode}" class="thumb" />`
261284
}
262285

286+
/**
287+
* Build the a11y badge (for the row header) and the collapsible violation list
288+
* (rule, impact, offending selector, and axe's failure summary) for a
289+
* screenshot. Returns empty strings and count 0 when there are no violations.
290+
*
291+
* @internal — exported only for tests; not part of the package's public API.
292+
*/
293+
export function a11yFor(
294+
name: string,
295+
a11y: A11y | null
296+
): { badge: string; details: string; count: number } {
297+
const slug = name.replace(/\.png$/, '')
298+
const violations = a11y?.[slug] ?? []
299+
const count = violations.length
300+
if (!count) return { badge: '', details: '', count: 0 }
301+
302+
const plural = count === 1 ? '' : 's'
303+
const badge = `<span class="pill a11y" title="${count} accessibility violation${plural}">⚠ ${count} a11y</span>`
304+
305+
const items = violations
306+
.map((v) => {
307+
const impact = v.impact
308+
? `<span class="a11y-impact ${esc(v.impact)}">${esc(v.impact)}</span> `
309+
: ''
310+
const rule = v.helpUrl
311+
? `<a href="${esc(
312+
v.helpUrl
313+
)}" target="_blank" rel="noopener"><code>${esc(v.id)}</code></a>`
314+
: `<code>${esc(v.id)}</code>`
315+
const nodes = v.nodes
316+
.map(
317+
(n) =>
318+
`<li><code class="sel">${esc(n.target)}</code>${
319+
n.summary
320+
? `<div class="a11y-summary">${esc(n.summary)}</div>`
321+
: ''
322+
}</li>`
323+
)
324+
.join('')
325+
return `<li>${impact}${rule}${esc(
326+
v.help
327+
)}<ul class="a11y-nodes">${nodes}</ul></li>`
328+
})
329+
.join('')
330+
331+
const details = `<details class="a11y-details"><summary>${count} accessibility violation${plural}</summary><ul class="a11y-list">${items}</ul></details>`
332+
return { badge, details, count }
333+
}
334+
263335
function row(
264336
r: Result,
265337
meta: Meta | null,
266338
sourceBaseUrl?: string,
267339
facets: string[] = [],
268-
appPath?: string
340+
appPath?: string,
341+
a11y: A11y | null = null
269342
): string {
270343
const b = r.status === 'added' ? '' : thumb('baseline', r.name)
271344
const a = r.status === 'removed' ? '' : thumb('actual', r.name)
@@ -283,14 +356,20 @@ function row(
283356
const htmlUrl =
284357
r.status === 'removed' ? '' : appUrlFor(r.name, meta, facets, appPath)
285358
const htmlAttr = htmlUrl ? ` data-html-url="${htmlUrl}"` : ''
359+
const {
360+
badge: a11yBadge,
361+
details: a11yDetails,
362+
count: a11yCount
363+
} = a11yFor(r.name, a11y)
286364
return `
287365
<section class="row" data-status="${r.status}" data-name="${
288-
r.name
289-
}" data-has-both="${hasBoth}"${htmlAttr}>
366+
r.name
367+
}" data-has-both="${hasBoth}" data-a11y="${a11yCount}"${htmlAttr}>
290368
<header><h2>${r.name}</h2>${badgeFor(
291-
r.status
292-
)}${pixelMeta}${source}</header>
369+
r.status
370+
)}${a11yBadge}${pixelMeta}${source}</header>
293371
<div class="grid"><figure><figcaption>Baseline</figcaption>${b}</figure><figure><figcaption>Actual</figcaption>${a}</figure><figure><figcaption>Diff</figcaption>${d}</figure></div>
372+
${a11yDetails}
294373
</section>`
295374
}
296375

@@ -302,14 +381,15 @@ function renderHtml(
302381
meta?: Meta | null,
303382
sourceBaseUrl?: string,
304383
facets: string[] = [],
305-
appPath?: string
384+
appPath?: string,
385+
a11y: A11y | null = null
306386
): string {
307387
const prBadge =
308388
prNumber && prUrl
309389
? `<a href="${prUrl}" style="font-weight:600;color:#0969da;text-decoration:none;">PR #${prNumber}</a>`
310390
: prNumber
311-
? `<span style="font-weight:600;">PR #${prNumber}</span>`
312-
: ''
391+
? `<span style="font-weight:600;">PR #${prNumber}</span>`
392+
: ''
313393
// Land on the "Changed" view by default so reviewers see regressions first,
314394
// but fall back to "All" when nothing changed (otherwise the list is blank).
315395
const defaultFilter = summary.changed > 0 ? 'changed' : 'all'
@@ -318,7 +398,8 @@ function renderHtml(
318398
['changed', 'Changed'],
319399
['added', 'New'],
320400
['removed', 'Removed'],
321-
['unchanged', 'Unchanged']
401+
['unchanged', 'Unchanged'],
402+
['a11y', '⚠ A11y']
322403
]
323404
return `<!doctype html>
324405
<html><head><meta charset="utf-8"><title>Visual regression report</title>
@@ -340,7 +421,19 @@ function renderHtml(
340421
.fail { background: #ffebe9; color: #cf222e; }
341422
.new { background: #ddf4ff; color: #0969da; }
342423
.gone { background: #fff1e5; color: #9a6700; }
424+
.a11y { background: #fbefff; color: #8250df; }
343425
.meta { font-size: 12px; color: #666; }
426+
.a11y-details { padding: 10px 16px; border-top: 1px solid #f0f0f0; font-size: 13px; }
427+
.a11y-details > summary { cursor: pointer; color: #8250df; font-weight: 600; }
428+
.a11y-list { margin: 10px 0 2px; padding-left: 18px; }
429+
.a11y-list > li { margin-bottom: 8px; }
430+
.a11y-nodes { margin: 4px 0 0; padding-left: 16px; list-style: circle; }
431+
.a11y-nodes code.sel { font-size: 12px; color: #57606a; word-break: break-all; }
432+
.a11y-summary { white-space: pre-wrap; color: #666; font-size: 12px; margin: 2px 0 6px; }
433+
.a11y-impact { padding: 1px 6px; border-radius: 8px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: #fff; }
434+
.a11y-impact.critical, .a11y-impact.serious { background: #cf222e; }
435+
.a11y-impact.moderate { background: #9a6700; }
436+
.a11y-impact.minor { background: #57606a; }
344437
.filter { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
345438
.filter button { font-size: 12px; padding: 4px 10px; border: 1px solid #ddd; background: #fff; border-radius: 4px; cursor: pointer; }
346439
.filter button.active { background: #0969da; color: #fff; border-color: #0969da; }
@@ -378,7 +471,12 @@ function renderHtml(
378471
<span style="color:#1a7f37;">OK: ${summary.unchanged}</span>
379472
<span style="color:#cf222e;">Changed: ${summary.changed}</span>
380473
<span style="color:#0969da;">New: ${summary.added}</span>
381-
<span style="color:#9a6700;">Removed: ${summary.removed}</span>
474+
<span style="color:#9a6700;">Removed: ${summary.removed}</span>${
475+
summary.a11yPages
476+
? `
477+
<span style="color:#8250df;">⚠ A11y: ${summary.a11yPages}</span>`
478+
: ''
479+
}
382480
</div>
383481
<div class="filter" id="status-filter">
384482
${statusFilters
@@ -402,7 +500,7 @@ function renderHtml(
402500
}
403501
</header>
404502
<main>${results
405-
.map((r) => row(r, meta ?? null, sourceBaseUrl, facets, appPath))
503+
.map((r) => row(r, meta ?? null, sourceBaseUrl, facets, appPath, a11y))
406504
.join('')}</main>
407505
408506
<div class="lightbox" id="lb" aria-hidden="true">
@@ -430,7 +528,8 @@ function renderHtml(
430528
function applyFilters() {
431529
const q = listState.query.toLowerCase()
432530
document.querySelectorAll('.row').forEach(r => {
433-
const matchesFilter = listState.filter === 'all' || r.dataset.status === listState.filter
531+
const matchesFilter = listState.filter === 'all'
532+
|| (listState.filter === 'a11y' ? Number(r.dataset.a11y) > 0 : r.dataset.status === listState.filter)
434533
const matchesQuery = !q || r.dataset.name.toLowerCase().includes(q)
435534
const matchesFacet = listState.facet === 'all' || r.dataset.name.includes('-' + listState.facet + '.')
436535
if (matchesFilter && matchesQuery && matchesFacet) r.removeAttribute('data-hidden')
@@ -664,12 +763,26 @@ function run(args: Args): number {
664763
results.push({ name, status, numDiff, sizeMismatch })
665764
}
666765

766+
let a11y: A11y | null = null
767+
if (args.a11y && existsSync(args.a11y)) {
768+
try {
769+
a11y = JSON.parse(readFileSync(args.a11y, 'utf8'))
770+
} catch {
771+
a11y = null
772+
}
773+
}
774+
const a11yEntries = a11y
775+
? Object.values(a11y).filter((v) => (v?.length ?? 0) > 0)
776+
: []
777+
667778
const summary = {
668779
total: results.length,
669780
unchanged: results.filter((r) => r.status === 'unchanged').length,
670781
changed: results.filter((r) => r.status === 'changed').length,
671782
added: results.filter((r) => r.status === 'added').length,
672-
removed: results.filter((r) => r.status === 'removed').length
783+
removed: results.filter((r) => r.status === 'removed').length,
784+
a11yPages: a11yEntries.length,
785+
a11yViolations: a11yEntries.reduce((n, v) => n + v.length, 0)
673786
}
674787

675788
writeFileSync(
@@ -700,7 +813,8 @@ function run(args: Args): number {
700813
meta,
701814
args.sourceBaseUrl,
702815
facets,
703-
args.appPath
816+
args.appPath,
817+
a11y
704818
)
705819
)
706820

@@ -770,6 +884,11 @@ export default {
770884
type: 'string',
771885
describe:
772886
'Path (relative to the report root) where the live app is published, e.g. "app". Enables an "HTML" view in the lightbox that iframes the rendered page for each screenshot.'
887+
},
888+
a11y: {
889+
type: 'string',
890+
describe:
891+
'Path to a JSON file of axe accessibility violations keyed by screenshot slug. Renders an a11y badge, an "⚠ A11y" filter, and a per-page violation list (rule, impact, selector, help link) in the report.'
773892
}
774893
},
775894
handler: (argv: Args) => {

regression-test/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ next-env.d.ts
4141
/cypress/downloads
4242
/cypress/screenshots
4343
/cypress/meta.json
44+
/cypress/a11y.json
4445

4546
# visual regression local runs
4647
/.actual

regression-test/cypress.config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import { defineConfig } from 'cypress'
2525
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2626

2727
const META_FILE = 'cypress/meta.json'
28+
// Accessibility violations captured per screenshot (`<slug>-<theme>`), consumed
29+
// by `ui-scripts visual-diff --a11y` to render a11y badges/details in the report.
30+
const A11Y_FILE = 'cypress/a11y.json'
2831

2932
export default defineConfig({
3033
screenshotsFolder: 'cypress/screenshots',
@@ -41,6 +44,7 @@ export default defineConfig({
4144
setupNodeEvents(on, config) {
4245
on('before:run', () => {
4346
writeFileSync(META_FILE, '{}')
47+
writeFileSync(A11Y_FILE, '{}')
4448
})
4549
on('task', {
4650
log(message) {
@@ -58,6 +62,20 @@ export default defineConfig({
5862
data[name] = pagePath
5963
writeFileSync(META_FILE, JSON.stringify(data, null, 2))
6064
return null
65+
},
66+
recordA11y({
67+
name,
68+
violations
69+
}: {
70+
name: string
71+
violations: unknown[]
72+
}) {
73+
const data = existsSync(A11Y_FILE)
74+
? JSON.parse(readFileSync(A11Y_FILE, 'utf8'))
75+
: {}
76+
data[name] = violations
77+
writeFileSync(A11Y_FILE, JSON.stringify(data, null, 2))
78+
return null
6179
}
6280
})
6381
}

0 commit comments

Comments
 (0)