Skip to content

Commit

Permalink
Merge pull request #2038 from adevinta/refacto-a11yscript-to-export
Browse files Browse the repository at this point in the history
refactor: refacto a11y check to prepare JSON export
  • Loading branch information
soykje authored Apr 24, 2024
2 parents 084a2ad + 7e8b013 commit 07c8ab2
Show file tree
Hide file tree
Showing 6 changed files with 450 additions and 56 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
name: quality
env:
FORCE_COLOR: 3
on:
pull_request:
branches:
Expand Down
13 changes: 10 additions & 3 deletions .storybook/preview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ShareExpand } from '@spark-ui/icons/dist/icons/ShareExpand'
import '../src/tailwind.css'
import './sb-theming.css'
import { ToC } from '@docs/helpers/ToC'
import { A11yReport } from '@docs/helpers/A11yReport'

const ExampleContainer = ({ children, ...props }) => {
const shouldDisplayExperimentalBanner = (() => {
Expand All @@ -21,12 +22,18 @@ const ExampleContainer = ({ children, ...props }) => {
return (
<DocsContainer {...props}>
<div id="spark-doc-container">
{shouldDisplayExperimentalBanner && <p id="experimental-banner">
This component is still experimental. Avoid usage in production features
</p>}
{shouldDisplayExperimentalBanner && (
<p id="experimental-banner">
This component is still experimental. Avoid usage in production features
</p>
)}
{children}
</div>
<ToC />

<A11yReport
of={`${props.context.channel.data.docsPrepared[0].id.split('-')[0]}/${props.context.channel.data.docsPrepared[0].id.split('-')[1]}`}
/>
</DocsContainer>
)
}
Expand Down
183 changes: 131 additions & 52 deletions bin/check-a11y.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { readFileSync } from 'fs'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import chalk from 'chalk'

import AxeBuilder from '@axe-core/webdriverjs'
Expand All @@ -14,7 +14,7 @@ const impactColor = {
critical: chalk.red,
}

const { entries: stories } = JSON.parse(readFileSync('dist/index.json', 'utf8'))
const { entries } = JSON.parse(readFileSync('dist/index.json', 'utf8'))

const hasFilter = process.argv.indexOf('-f') > -1
const pkgFilter = hasFilter
Expand All @@ -23,102 +23,181 @@ const pkgFilter = hasFilter
.map(pkgPath => `./${pkgPath.split('/src')[0]}`) ?? []
: []

const storiesList = Object.keys(stories).reduce((acc, cur) => {
const isComponentStory = story =>
story.importPath.startsWith('./packages/components/') && !story.id.endsWith('--docs')
const belongsToFilter = (story, filter) =>
filter.includes(stories[cur].importPath.split('/src')[0])
const docsList = Object.keys(entries).reduce((acc, cur) => {
const isComponentDoc = entry =>
entry.importPath.startsWith('./packages/components/') && !!entry.id.endsWith('--docs')

if (isComponentStory(stories[cur])) {
if (pkgFilter.length && !belongsToFilter(stories[cur], pkgFilter)) {
const belongsToFilter = (entry, filter) =>
filter.includes(entries[cur].importPath.split('/src')[0])

if (isComponentDoc(entries[cur])) {
if (pkgFilter.length && !belongsToFilter(entries[cur], pkgFilter)) {
return acc
}

// We'll test the whole stories collection if no filter is provided.
acc.push(stories[cur].id)
// We'll test the whole docs if no filter is provided.
acc.push(entries[cur].id)
}

return acc
}, [])

let issues = {
minor: 0,
moderate: 0,
serious: 0,
critical: 0,
const mergeResultArrays = (newArray, originArray = []) => {
const mergedNodes = (a, b = []) =>
[...a, ...b].reduce((acc, curr) => {
const { target, ...rest } = curr

const mergedNode = {
...rest,
target: [
...new Set([...acc.reduce((prev, curr) => [...prev, ...curr.target], []), ...target]),
],
}

return [...acc, mergedNode]
}, [])

return [...originArray, ...newArray].reduce((acc, currentRule) => {
const duplicateRule = acc.filter(item => item.id === currentRule.id)

if (duplicateRule.length > 0) {
const uniqRules = acc.filter(item => item.id !== currentRule.id)

const duplicateRuleNodes = duplicateRule.reduce((acc, curr) => {
const { nodes, ...rest } = curr
return [...acc, ...nodes]
}, [])

const nodes = mergedNodes(duplicateRuleNodes, currentRule.nodes)

return [
...uniqRules,
{
...currentRule,
nodes: [nodes[nodes.length - 1]],
},
]
} else {
const nodes = mergedNodes(currentRule.nodes)

return [
...acc,
{
...currentRule,
nodes: [nodes[nodes.length - 1]],
},
]
}
}, [])
}

let report = {}

/**
*
* Informations about Axe API available here:
* https://github.com/dequelabs/axe-core/blob/master/doc/API.md
*/
const checkA11y = async () => {
const driver = new Builder()
.forBrowser('chrome')
.setChromeOptions(new Options().addArguments('-headless'))
.build()

let testedStory
let testedComponent

for (const storyId of storiesList) {
if (testedStory !== stories[storyId].title)
console.log(`Checking accessibility for ${stories[storyId].title}...`)
for (const docId of docsList) {
if (testedComponent !== entries[docId].title) {
console.log(`Checking accessibility for ${entries[docId].title}...`)
}

testedStory = stories[storyId].title
testedComponent = entries[docId].title.toLowerCase()

try {
await driver.get(`http://localhost:6006/iframe.html?viewMode=story&id=${storyId}`)
await driver.get(`http://localhost:6006/?path=/docs/${docId}`)

const results = await new AxeBuilder(driver)
.options({
resultTypes: ['violations', 'incomplete'],
rules: {
// 'meta-viewport': { enabled: false },
'page-has-heading-one': { enabled: false },
'landmark-one-main': { enabled: false },
region: { enabled: false },
'color-contrast': { enabled: false },
},
runOnly: ['best-practice', 'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
reporter: 'no-passes',
reporter: 'v2',
})
.exclude('.docblock-argstable')
.exclude('*:not(.docs-story)')
.analyze()

if (results.violations.length) {
issues = results.violations.reduce(
(acc, cur) => {
acc[cur.impact] += cur.nodes.length
return acc
},
{
minor: 0,
moderate: 0,
serious: 0,
critical: 0,
}
)

const totalIssues = Object.values(issues).reduce((a, b) => a + b, 0)
console.log(chalk.red(`Found ${totalIssues} ${stories[storyId].title} issue(s)):`))

results.violations.forEach(issue => {
console.log(
impactColor[issue.impact](`- [${issue.impact}] ${issue.help} (${issue.nodes.length})`)
)
})
const duplicate = report[testedComponent]
const { [testedComponent]: _, ...rest } = report

const { timestamp, url, incomplete, violations } = results

report = {
...rest,
[testedComponent]: {
timestamp,
...(duplicate ? { url: [...duplicate.url, url] } : { url: [url] }),
...(duplicate
? { incomplete: mergeResultArrays(incomplete, duplicate.incomplete) }
: { incomplete: mergeResultArrays(incomplete) }),
...(duplicate
? { violations: mergeResultArrays(violations, duplicate.violations) }
: { violations: mergeResultArrays(violations) }),
},
}
} catch (e) {
console.error(chalk.bold.red(e.message))
return
}
}

const totalIssues = Object.values(issues).reduce((a, b) => a + b, 0)
if (totalIssues > 0 && issues.critical) {
/**
* Export results as JSON file to Storybook static directory
*/
writeFileSync(`./public/a11y-report.json`, JSON.stringify(report, null, 2), 'utf8')

/**
* Log violations & incompletes for each component tested
*/
Object.keys(report).forEach(componentName => {
if (!report[componentName].violations.length && !report[componentName].incomplete.length) return

console.log(chalk.bold.red(`[${componentName}]:`))

const { violations, incomplete } = report[componentName]
const issues = [...violations, ...incomplete]

issues.forEach(issue => {
console.log(
impactColor[issue.impact](
`- [${issue.id}]: ${issue.help} [${issue.impact}] (${issue.nodes[0].target.length})`
)
)
})
})

/**
* Exit and fail if any critical issue is found
*/
const criticalIssues = Object.keys(report).reduce((sum, componentName) => {
const { violations, incomplete } = report[componentName]
const issues = [...violations, ...incomplete].filter(issue => issue.impact === 'critical')

return sum + issues.reduce((acc, curr) => acc + curr.nodes[0].target.length, 0)
}, 0)

if (criticalIssues > 0) {
console.error()
console.error(chalk.bold.red(`Exiting with ${issues.critical} critical issue(s)`))
console.error(chalk.bold.red(`💥 Exiting with ${criticalIssues} critical issue(s)`))
process.exit(1)
} else {
console.log()
console.log(
chalk.bold.green('🎉 Congratulations, no critical accessibility issue has been found!')
)
console.log(chalk.bold.green('🎉 No critical accessibility issue has been found'))
}

await driver.quit()
Expand Down
24 changes: 24 additions & 0 deletions documentation/helpers/A11yReport/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect } from 'react'

interface A11yReportProps {
of: string
}

/**
* For now we only will display accessibility report
* in browser console.
* This component may evolve and display data in a more visible way.
*/
export const A11yReport = ({ of }: A11yReportProps) => {
useEffect(() => {
const fetchReport = async (name: string) => {
const report = await fetch('/a11y-report.json')
.then(response => response.json())
.catch(() => console.error('Unable to find accessibility report'))

console.log(`${name} accessibility report:`, report[name])
}

fetchReport(of)
}, [of])
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"prettier": "prettier --config .prettierrc.cjs --write \"{packages,documentation,src}/**/*.{ts,tsx,js}\"",
"prettify": "npm run lint && npm run prettier",
"node:check": "node bin/check-node-version.js",
"a11y:check": "concurrently -P --kill-others -g -s first -n storybook,a11y \"storybook build -o dist --loglevel silent && npx http-server dist -p 6006 -s\" \"wait-on tcp:6006 && node bin/check-a11y.js -- {@} \"",
"a11y:check": "concurrently -P --kill-others -g -s first -n storybook,a11y \"storybook build -o dist --loglevel silent && npx http-server dist -p 6008 -s\" \"wait-on tcp:6008 && node bin/check-a11y.js -- {@} \"",
"generate": "spark-generate",
"codecov:check-yml": "cat .codecov.yml | curl --data-binary @- https://codecov.io/validate"
},
Expand Down
Loading

0 comments on commit 07c8ab2

Please sign in to comment.