diff --git a/.github/actions/file/src/generateIssueBody.ts b/.github/actions/file/src/generateIssueBody.ts
index 18e25d31..b20ca00f 100644
--- a/.github/actions/file/src/generateIssueBody.ts
+++ b/.github/actions/file/src/generateIssueBody.ts
@@ -25,7 +25,7 @@ export function generateIssueBody(finding: Finding, screenshotRepo: string): str
- [ ] This PR MUST NOT introduce any new accessibility issues or regressions.`
const body = `## What
-An accessibility scan ${finding.html ? `flagged the element \`${finding.html}\`` : `found an issue on ${finding.url}`} because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}.
+${describeWhat(finding)}
${screenshotSection ?? ''}
To fix this, ${finding.solutionShort}.
@@ -36,3 +36,24 @@ ${acceptanceCriteria}
return body
}
+
+function describeWhat(finding: Finding): string {
+ const reason = `because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}.`
+
+ // Axe carries every failing element; list them all, not just the first.
+ if (finding.nodes && finding.nodes.length > 0) {
+ const count = finding.nodes.length
+ const subject = count === 1 ? 'an element' : `${count} elements`
+ const elementList = finding.nodes
+ .map(node => `- \`${node.html}\`${node.target ? ` (selector: \`${node.target}\`)` : ''}`)
+ .join('\n')
+ const heading = count === 1 ? 'The following element needs' : 'The following elements need'
+ return `An accessibility scan flagged ${subject} on ${finding.url} ${reason}\n\n${heading} attention:\n\n${elementList}`
+ }
+
+ if (finding.html) {
+ return `An accessibility scan flagged the element \`${finding.html}\` ${reason}`
+ }
+
+ return `An accessibility scan found an issue on ${finding.url} ${reason}`
+}
diff --git a/.github/actions/file/src/types.d.ts b/.github/actions/file/src/types.d.ts
index ee91bc67..ca7b71a8 100644
--- a/.github/actions/file/src/types.d.ts
+++ b/.github/actions/file/src/types.d.ts
@@ -1,8 +1,14 @@
+export type FindingNode = {
+ html: string
+ target?: string
+}
+
export type Finding = {
scannerType: string
ruleId?: string
url: string
html?: string
+ nodes?: FindingNode[]
problemShort: string
problemUrl: string
solutionShort: string
diff --git a/.github/actions/file/src/updateFilingsWithNewFindings.ts b/.github/actions/file/src/updateFilingsWithNewFindings.ts
index eee1c6ae..2ab0a532 100644
--- a/.github/actions/file/src/updateFilingsWithNewFindings.ts
+++ b/.github/actions/file/src/updateFilingsWithNewFindings.ts
@@ -5,6 +5,11 @@ function getFilingKey(filing: ResolvedFiling | RepeatedFiling): string {
}
function getFindingKey(finding: Finding): string {
+ // Axe groups every failing element under one rule, so key on the rule, not the
+ // element's HTML, which shifts with DOM changes and re-files tracked issues.
+ if (finding.scannerType === 'axe' && finding.ruleId) {
+ return `${finding.url};axe;${finding.ruleId}`
+ }
if (finding.ruleId && finding.html) {
return `${finding.url};${finding.ruleId};${finding.html}`
}
diff --git a/.github/actions/file/tests/generateIssueBody.test.ts b/.github/actions/file/tests/generateIssueBody.test.ts
index 167ee5f8..976aa6de 100644
--- a/.github/actions/file/tests/generateIssueBody.test.ts
+++ b/.github/actions/file/tests/generateIssueBody.test.ts
@@ -76,4 +76,23 @@ describe('generateIssueBody', () => {
expect(body).toContain(`found an issue on ${findingWithEmptyOptionalFields.url}`)
expect(body).not.toContain('flagged the element')
})
+
+ it('lists every node when the finding carries multiple elements', () => {
+ const body = generateIssueBody(
+ {
+ ...baseFinding,
+ html: 'first',
+ nodes: [
+ {html: 'first', target: 'span.first'},
+ {html: 'link', target: 'a.link'},
+ ],
+ },
+ 'github/accessibility-scanner',
+ )
+
+ expect(body).toContain('flagged 2 elements')
+ expect(body).toContain('- `first` (selector: `span.first`)')
+ expect(body).toContain('- `link` (selector: `a.link`)')
+ expect(body).not.toContain('flagged the element')
+ })
})
diff --git a/.github/actions/file/tests/updateFilingsWithNewFindings.test.ts b/.github/actions/file/tests/updateFilingsWithNewFindings.test.ts
new file mode 100644
index 00000000..a1e447e7
--- /dev/null
+++ b/.github/actions/file/tests/updateFilingsWithNewFindings.test.ts
@@ -0,0 +1,61 @@
+import {describe, it, expect} from 'vitest'
+import {updateFilingsWithNewFindings} from '../src/updateFilingsWithNewFindings.ts'
+import type {Finding, RepeatedFiling} from '../src/types.d.ts'
+
+const cachedFinding: Finding = {
+ scannerType: 'axe',
+ ruleId: 'color-contrast',
+ url: 'https://example.com/',
+ html: 'old markup',
+ nodes: [{html: 'old markup', target: 'span.post-meta'}],
+ problemShort: 'elements must meet minimum color contrast ratio thresholds',
+ problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=playwright',
+ solutionShort: 'ensure the contrast meets WCAG thresholds',
+}
+
+const cachedFiling: RepeatedFiling = {
+ issue: {
+ id: 1,
+ nodeId: 'node-1',
+ url: 'https://github.com/org/repo/issues/1',
+ title: 'Accessibility issue: color contrast on /',
+ },
+ findings: [cachedFinding],
+}
+
+describe('updateFilingsWithNewFindings', () => {
+ it('re-matches an axe finding to its existing issue after the element HTML shifts', () => {
+ // Same rule and page, but the element's markup shifted; should still map to issue #1.
+ const shiftedFinding: Finding = {
+ ...cachedFinding,
+ html: 'old markup wrapped in a new container',
+ nodes: [
+ {html: 'old markup wrapped in a new container', target: 'div > span.post-meta'},
+ ],
+ }
+
+ const result = updateFilingsWithNewFindings([cachedFiling], [shiftedFinding])
+
+ expect(result).toHaveLength(1)
+ const filing = result[0] as RepeatedFiling
+ expect(filing.issue.url).toBe('https://github.com/org/repo/issues/1')
+ expect(filing.findings).toHaveLength(1)
+ expect(filing.findings[0].html).toContain('new container')
+ })
+
+ it('files a new issue when a different rule fails on the same page', () => {
+ const differentRule: Finding = {
+ ...cachedFinding,
+ ruleId: 'image-alt',
+ html: '
',
+ nodes: [{html: '
', target: 'img'}],
+ }
+
+ const result = updateFilingsWithNewFindings([cachedFiling], [differentRule])
+
+ expect(result).toHaveLength(2)
+ const newFilings = result.filter(filing => filing.issue === undefined)
+ expect(newFilings).toHaveLength(1)
+ expect(newFilings[0].findings[0].ruleId).toBe('image-alt')
+ })
+})
diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts
index d9f1ea87..db31c6de 100644
--- a/.github/actions/find/src/findForUrl.ts
+++ b/.github/actions/find/src/findForUrl.ts
@@ -85,10 +85,15 @@ async function runAxeScan({
if (rawFindings) {
for (const violation of rawFindings.violations) {
+ // Capture every failing element, not just the first, so one issue covers the rule.
await addFinding({
scannerType: 'axe',
url,
html: violation.nodes[0].html.replace(/'/g, '''),
+ nodes: violation.nodes.map(node => ({
+ html: node.html.replace(/'/g, '''),
+ target: node.target.map(part => (Array.isArray(part) ? part.join(' ') : part)).join(' '),
+ })),
problemShort: violation.help.toLowerCase().replace(/'/g, '''),
problemUrl: violation.helpUrl.replace(/'/g, '''),
ruleId: violation.id,
diff --git a/.github/actions/find/src/types.d.ts b/.github/actions/find/src/types.d.ts
index dcbc8600..d615ea56 100644
--- a/.github/actions/find/src/types.d.ts
+++ b/.github/actions/find/src/types.d.ts
@@ -1,7 +1,13 @@
+export type FindingNode = {
+ html: string
+ target?: string
+}
+
export type Finding = {
scannerType: string
url: string
html?: string
+ nodes?: FindingNode[]
problemShort: string
problemUrl: string
solutionShort: string
diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts
index 85299c5c..9249a691 100644
--- a/.github/actions/find/tests/findForUrl.test.ts
+++ b/.github/actions/find/tests/findForUrl.test.ts
@@ -117,4 +117,32 @@ describe('findForUrl', () => {
expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0)
})
})
+
+ it('captures every failing element of an axe violation as nodes', async () => {
+ actionInput = ''
+ clearAll()
+
+ const violation = {
+ id: 'color-contrast',
+ help: 'Elements must meet minimum color contrast ratio thresholds',
+ helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast',
+ description: 'Ensure contrast meets WCAG thresholds',
+ nodes: [
+ {html: 'one', target: ['span.one'], failureSummary: 'Fix any of the following:'},
+ {html: 'two', target: ['div', 'span.two'], failureSummary: 'Fix any of the following:'},
+ ],
+ }
+ vi.mocked(AxeBuilder.prototype.analyze).mockResolvedValueOnce({
+ violations: [violation],
+ } as unknown as axe.AxeResults)
+
+ const findings = await findForUrl('test.com')
+
+ expect(findings).toHaveLength(1)
+ expect(findings[0].html).toBe('one')
+ expect(findings[0].nodes).toEqual([
+ {html: 'one', target: 'span.one'},
+ {html: 'two', target: 'div span.two'},
+ ])
+ })
})
diff --git a/tests/site-with-errors.test.ts b/tests/site-with-errors.test.ts
index 8c7981bd..69948628 100644
--- a/tests/site-with-errors.test.ts
+++ b/tests/site-with-errors.test.ts
@@ -39,13 +39,16 @@ describe('site-with-errors', () => {
it('cache has expected results', () => {
const actual = results.map(({issue: {url: issueUrl}, findings}) => {
- const {problemUrl, solutionLong, screenshotId, ...finding} = findings[0]
+ const {problemUrl, solutionLong, screenshotId, nodes, ...finding} = findings[0]
// Check volatile fields for existence only
expect(issueUrl).toBeDefined()
expect(problemUrl).toBeDefined()
// Axe-specific assertions
if (finding.scannerType === 'axe') {
expect(solutionLong).toBeDefined()
+ expect(nodes).toBeDefined()
+ expect(nodes!.length).toBeGreaterThan(0)
+ expect(nodes![0].html).toBe(finding.html)
expect(problemUrl.startsWith('https://dequeuniversity.com/rules/axe/')).toBe(true)
expect(problemUrl.endsWith(`/${finding.ruleId}?application=playwright`)).toBe(true)
}
diff --git a/tests/types.d.ts b/tests/types.d.ts
index b12077ae..ea72c54b 100644
--- a/tests/types.d.ts
+++ b/tests/types.d.ts
@@ -1,8 +1,14 @@
+export type FindingNode = {
+ html: string
+ target?: string
+}
+
export type Finding = {
scannerType: string
ruleId?: string
url: string
html?: string
+ nodes?: FindingNode[]
problemShort: string
problemUrl: string
solutionShort: string