Skip to content

Commit 8d2bbf0

Browse files
authored
Distinguish wcag vs best practice (#233)
Closes #34 (which closes github/accessibility#10749) ## Problem Every finding was filed as if it were a hard WCAG failure, with no way to differentiate definitive violations and best-practice or experimental checks. There was also no way to stop filing the non-WCAG ones. ## Changes - Classify each Axe finding as `wcag`, `best-practice`, or `experimental` - Surface non-WCAG findings in the filed issue: a "**Note:** …not a hard WCAG failure" line in the body and a `best-practice`/`experimental`label. - Add two action inputs, `file_best_practice_issues` and`file_experimental_issues` (default `true`), to skip filing those categories
2 parents 26c8030 + b41d9f0 commit 8d2bbf0

13 files changed

Lines changed: 197 additions & 23 deletions

File tree

.github/actions/file/action.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ inputs:
2828
description: "When true, log the issues that would be filed without opening, closing, or reopening any issues."
2929
required: false
3030
default: "false"
31+
file_best_practice_issues:
32+
description: "File issues for best-practice findings (accessibility recommendations that are not hard WCAG failures). Disabling only suppresses new issues; existing ones are left untouched."
33+
required: false
34+
default: "true"
35+
file_experimental_issues:
36+
description: "File issues for experimental findings (checks that are not yet stable). Disabling only suppresses new issues; existing ones are left untouched."
37+
required: false
38+
default: "true"
3139

3240
outputs:
3341
filings_file:

.github/actions/file/src/generateIssueBody.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,25 @@ export function generateIssueBody(finding: Finding, screenshotRepo: string): str
1818
`
1919
}
2020

21+
const categoryNotice =
22+
finding.category && finding.category !== 'wcag'
23+
? `> [!NOTE]\n> This is ${
24+
finding.category === 'experimental' ? 'an experimental check' : 'a best-practice recommendation'
25+
}, not a definite WCAG failure.\n\n`
26+
: ''
27+
28+
const standardsLine =
29+
finding.category && finding.category !== 'wcag'
30+
? '- [ ] The fix MUST meet the accessibility standards specified by the repository or organization (WCAG 2.2 if applicable).'
31+
: '- [ ] The fix MUST meet WCAG 2.2 guidelines OR the accessibility standards specified by the repository or organization.'
32+
2133
const acceptanceCriteria = `## Acceptance Criteria
2234
- [ ] The specific violation reported in this issue is no longer reproducible.
23-
- [ ] The fix MUST meet WCAG 2.1 guidelines OR the accessibility standards specified by the repository or organization.
35+
${standardsLine}
2436
- [ ] A test SHOULD be added to ensure this specific violation does not regress.
2537
- [ ] This PR MUST NOT introduce any new accessibility issues or regressions.`
2638

27-
const body = `## What
39+
const body = `${categoryNotice}## What
2840
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}.
2941
3042
${screenshotSection ?? ''}

.github/actions/file/src/index.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js'
1717
import {OctokitResponse} from '@octokit/types'
1818
const OctokitWithThrottling = Octokit.plugin(throttling)
1919

20+
// core.getBooleanInput throws on unset inputs, so apply the default first.
21+
function getBooleanInputWithDefault(name: string, defaultValue: boolean): boolean {
22+
if (!core.getInput(name)) return defaultValue
23+
return core.getBooleanInput(name)
24+
}
25+
2026
export default async function () {
2127
core.info("Started 'file' action")
2228
const findingsFile = core.getInput('findings_file', {required: true})
@@ -31,13 +37,17 @@ export default async function () {
3137
: []
3238
const shouldOpenGroupedIssues = core.getBooleanInput('open_grouped_issues')
3339
const dryRun = core.getBooleanInput('dry_run')
40+
const fileBestPracticeIssues = getBooleanInputWithDefault('file_best_practice_issues', true)
41+
const fileExperimentalIssues = getBooleanInputWithDefault('file_experimental_issues', true)
3442
core.debug(`Input: 'findings_file: ${findingsFile}'`)
3543
core.debug(`Input: 'repository: ${repoWithOwner}'`)
3644
core.debug(`Input: 'base_url: ${baseUrl ?? '(default)'}'`)
3745
core.debug(`Input: 'screenshot_repository: ${screenshotRepo}'`)
3846
core.debug(`Input: 'cached_filings_file: ${cachedFilingsFile}'`)
3947
core.debug(`Input: 'open_grouped_issues: ${shouldOpenGroupedIssues}'`)
4048
core.debug(`Input: 'dry_run: ${dryRun}'`)
49+
core.debug(`Input: 'file_best_practice_issues: ${fileBestPracticeIssues}'`)
50+
core.debug(`Input: 'file_experimental_issues: ${fileExperimentalIssues}'`)
4151

4252
const octokit = new OctokitWithThrottling({
4353
auth: token,
@@ -61,6 +71,9 @@ export default async function () {
6171
})
6272
const filings = updateFilingsWithNewFindings(cachedFilings, findings)
6373

74+
// Suppressed new filings are kept out of the cache
75+
const suppressedFilings = new Set<Filing>()
76+
6477
// Fetch closed wontfix issues once up front; a failed fetch reopens as usual
6578
let wontfixIssueNumbers = new Set<number>()
6679
if (!dryRun) {
@@ -80,6 +93,21 @@ export default async function () {
8093
for (const filing of filings) {
8194
let response: OctokitResponse<IssueResponse> | undefined
8295
try {
96+
// Category switches gate only new issues
97+
if (isNewFiling(filing)) {
98+
const category = filing.findings[0].category ?? 'wcag'
99+
if (
100+
(category === 'best-practice' && !fileBestPracticeIssues) ||
101+
(category === 'experimental' && !fileExperimentalIssues)
102+
) {
103+
core.info(
104+
`Skipping new ${category} issue (filing disabled for this category): ${filing.findings[0].problemShort}`,
105+
)
106+
suppressedFilings.add(filing)
107+
continue
108+
}
109+
}
110+
83111
if (dryRun) {
84112
if (isResolvedFiling(filing)) {
85113
dryRunCounts.close++
@@ -182,7 +210,8 @@ export default async function () {
182210
}
183211

184212
const filingsPath = path.join(process.env.RUNNER_TEMP || '/tmp', `filings-${crypto.randomUUID()}.json`)
185-
fs.writeFileSync(filingsPath, JSON.stringify(filings))
213+
const outputFilings = suppressedFilings.size > 0 ? filings.filter(f => !suppressedFilings.has(f)) : filings
214+
fs.writeFileSync(filingsPath, JSON.stringify(outputFilings))
186215
core.setOutput('filings_file', filingsPath)
187216

188217
core.debug(`Output: 'filings_file: ${filingsPath}'`)

.github/actions/file/src/openIssue.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export async function openIssue(octokit: Octokit, repoWithOwner: string, finding
2626
if (finding.ruleId) {
2727
labels.push(`${finding.scannerType} rule: ${finding.ruleId}`)
2828
}
29+
// Flag non-WCAG findings so they can be filtered or triaged separately
30+
if (finding.category && finding.category !== 'wcag') {
31+
labels.push(finding.category)
32+
}
2933

3034
const title = truncateWithEllipsis(
3135
`Accessibility issue: ${finding.problemShort[0].toUpperCase() + finding.problemShort.slice(1)} on ${new URL(finding.url).pathname}`,

.github/actions/file/src/types.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
export type FindingCategory = 'wcag' | 'best-practice' | 'experimental'
2+
13
export type Finding = {
24
scannerType: string
5+
category?: FindingCategory
36
ruleId?: string
47
url: string
58
html?: string

.github/actions/file/tests/generateIssueBody.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('generateIssueBody', () => {
2626
expect(body).toContain('## What')
2727
expect(body).toContain('## Acceptance Criteria')
2828
expect(body).toContain('The specific violation reported in this issue is no longer reproducible.')
29+
expect(body).toContain('The fix MUST meet WCAG 2.2 guidelines OR')
2930
expect(body).not.toContain('Specifically:')
3031
})
3132

@@ -76,4 +77,31 @@ describe('generateIssueBody', () => {
7677
expect(body).toContain(`found an issue on ${findingWithEmptyOptionalFields.url}`)
7778
expect(body).not.toContain('flagged the element')
7879
})
80+
81+
it('omits the category notice for WCAG findings', () => {
82+
expect(generateIssueBody(baseFinding, 'github/accessibility-scanner')).not.toContain('> [!NOTE]')
83+
expect(generateIssueBody({...baseFinding, category: 'wcag'}, 'github/accessibility-scanner')).not.toContain(
84+
'> [!NOTE]',
85+
)
86+
})
87+
88+
it('includes a best-practice notice for best-practice findings', () => {
89+
const body = generateIssueBody({...baseFinding, category: 'best-practice'}, 'github/accessibility-scanner')
90+
91+
expect(body).toContain('> [!NOTE]')
92+
expect(body).toContain('best-practice recommendation')
93+
expect(body).toContain('not a definite WCAG failure')
94+
expect(body).toContain('WCAG 2.2 if applicable')
95+
expect(body).not.toContain('The fix MUST meet WCAG 2.2 guidelines OR')
96+
})
97+
98+
it('includes an experimental notice for experimental findings', () => {
99+
const body = generateIssueBody({...baseFinding, category: 'experimental'}, 'github/accessibility-scanner')
100+
101+
expect(body).toContain('> [!NOTE]')
102+
expect(body).toContain('an experimental check')
103+
expect(body).toContain('not a definite WCAG failure')
104+
expect(body).toContain('WCAG 2.2 if applicable')
105+
expect(body).not.toContain('The fix MUST meet WCAG 2.2 guidelines OR')
106+
})
79107
})

.github/actions/file/tests/openIssue.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ describe('openIssue', () => {
6565
)
6666
})
6767

68+
it('adds a category label for non-WCAG findings', async () => {
69+
const octokit = mockOctokit()
70+
await openIssue(octokit, 'org/repo', {...baseFinding, category: 'best-practice'})
71+
72+
expect(octokit.request).toHaveBeenCalledWith(
73+
expect.any(String),
74+
expect.objectContaining({
75+
labels: ['axe-scanning-issue', 'axe rule: color-contrast', 'best-practice'],
76+
}),
77+
)
78+
})
79+
80+
it('does not add a category label for WCAG findings', async () => {
81+
const octokit = mockOctokit()
82+
await openIssue(octokit, 'org/repo', {...baseFinding, category: 'wcag'})
83+
84+
const labels = octokit.request.mock.calls[0][1].labels
85+
expect(labels).not.toContain('wcag')
86+
expect(labels).not.toContain('best-practice')
87+
expect(labels).not.toContain('experimental')
88+
})
89+
6890
it('truncates long titles with ellipsis', async () => {
6991
const octokit = mockOctokit()
7092
const longFinding = {

.github/actions/find/src/findForUrl.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {ColorSchemePreference, Finding, ReducedMotionPreference, UrlConfig} from './types.d.js'
1+
import type {ColorSchemePreference, Finding, FindingCategory, ReducedMotionPreference, UrlConfig} from './types.d.js'
22
import {AxeBuilder} from '@axe-core/playwright'
33
import playwright from 'playwright'
44
import {AuthContext} from './AuthContext.js'
@@ -87,6 +87,7 @@ async function runAxeScan({
8787
for (const violation of rawFindings.violations) {
8888
await addFinding({
8989
scannerType: 'axe',
90+
category: categorizeAxeViolation(violation.tags),
9091
url,
9192
html: violation.nodes[0].html.replace(/'/g, '&apos;'),
9293
problemShort: violation.help.toLowerCase().replace(/'/g, '&apos;'),
@@ -98,3 +99,11 @@ async function runAxeScan({
9899
}
99100
}
100101
}
102+
103+
// Maps an Axe violation's tags to a conformance tier. Experimental is checked
104+
// first because some experimental rules also carry a wcag* tag.
105+
function categorizeAxeViolation(tags: string[]): FindingCategory {
106+
if (tags.includes('experimental')) return 'experimental'
107+
if (tags.includes('best-practice')) return 'best-practice'
108+
return 'wcag'
109+
}

.github/actions/find/src/types.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
export type FindingCategory = 'wcag' | 'best-practice' | 'experimental'
2+
13
export type Finding = {
24
scannerType: string
5+
category?: FindingCategory
36
url: string
47
html?: string
58
problemShort: string

.github/actions/find/tests/findForUrl.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,40 @@ describe('findForUrl', () => {
117117
expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0)
118118
})
119119
})
120+
121+
describe('axe finding categorization', () => {
122+
function axeViolation(tags: string[]) {
123+
return {
124+
id: 'some-rule',
125+
help: 'Help',
126+
helpUrl: 'https://example.com',
127+
description: 'Description',
128+
tags,
129+
nodes: [{html: '<div></div>', failureSummary: 'summary'}],
130+
}
131+
}
132+
133+
async function categoryFor(tags: string[]) {
134+
clearAll()
135+
actionInput = JSON.stringify(['axe'])
136+
vi.mocked(AxeBuilder.prototype.analyze).mockResolvedValueOnce({
137+
violations: [axeViolation(tags)],
138+
} as unknown as axe.AxeResults)
139+
140+
const findings = await findForUrl('test.com')
141+
return findings[0].category
142+
}
143+
144+
it('categorizes a violation with only wcag tags as wcag', async () => {
145+
expect(await categoryFor(['wcag2a', 'wcag111'])).toBe('wcag')
146+
})
147+
148+
it('categorizes a violation with a best-practice tag as best-practice', async () => {
149+
expect(await categoryFor(['cat.semantics', 'best-practice'])).toBe('best-practice')
150+
})
151+
152+
it('categorizes a violation with an experimental tag as experimental, even alongside wcag tags', async () => {
153+
expect(await categoryFor(['wcag2a', 'experimental'])).toBe('experimental')
154+
})
155+
})
120156
})

0 commit comments

Comments
 (0)