pa11y
Version:
Pa11y is your automated accessibility testing pal
225 lines (202 loc) • 5.99 kB
JavaScript
;
const path = require('path');
const axePath = path.dirname(require.resolve('axe-core'));
/**
* Axe's types
* @typedef { import('axe-core') } Axe
* @typedef { import('axe-core').Selector } AxeSelector
* @typedef { import('axe-core').Rule } AxeRule
* @typedef { import('axe-core').ImpactValue } AxeImpact
* @typedef { import('axe-core').Result } AxeResult
* @typedef { import('axe-core').RunOptions } AxeRunOptions
* @typedef { import('axe-core').RunOnly } AxeRunOnly
*/
/**
* @import { Pa11yConfiguration, Pa11yStandard, Pa11yLevel } from '../pa11y'
*/
const run = async options => {
/**
* @returns {Axe} Axe
*/
const getBrowserAxe = () => (window || global).axe;
/**
* Get the proper context to pass to axe, according to the specified Pa11y options. It can be an
* HTML element or a CSS selector, ready to be used in `axe.run()`
* @param {Node | string} rootElement Root element
* @returns {Node | string} Axe context element
*/
function getAxeContext(rootElement) {
return rootElement || window.document;
}
/**
* Create a configuration for `axe.run()` corresponding to Pa11y's current configuration
* @param {Pa11yConfiguration} options Pa11y's configuration
* @returns {AxeRunOptions} Axe configuration
*/
const getAxeOptions = ({standard, rules = [], ignore = []}) => ({
rules: pa11yRulesToAxe(rules, ignore),
...(standard && {
runOnly: createAxeRunOnlyTags(standard)
})
});
/**
* Convert an axe selector array to a selector string.
* @param {Array<string>} selectors - The selector parts.
* @returns {string} Selector string.
*/
const selectorToString = selectors => selectors.join(' ');
/**
* Map Pa11y's 'standard' to axe's 'runOnly'.
* @param {Pa11yStandard} pa11yStandard Provided Pa11y standard (upper bound)
* @returns {AxeRunOnly} Axe's `runOnly` value.
*/
const createAxeRunOnlyTags = pa11yStandard => ({
type: 'tags',
values: [
'wcag2a',
'wcag21a',
...(pa11yStandard === 'WCAG2A' ? [] : [
'wcag2aa',
'wcag21aa'
]),
'best-practice'
]
});
/**
* Map the Pa11y rules option to the axe rules option
* @param {Array<string>} rules List of Pa11y rules
* @param {Array<string>} ignore List of Pa11y rules to ignore
* @returns {object} Axe rules value
*/
const pa11yRulesToAxe = (rules, ignore) => {
return Object.assign(
{},
...getBrowserAxe()
.getRules()
.map(({ruleId}) => ruleId.toLowerCase())
.map(id => createAxeRule(id, rules, ignore))
.filter(rule => rule)
);
};
const createAxeRule = (axeId, pa11yRules, pa11yIgnore) => {
if (pa11yRules.includes(axeId)) {
return ({
[axeId]: {
enabled: true
}
});
}
if (pa11yIgnore.includes(axeId)) {
return ({
[axeId]: {
enabled: false
}
});
}
return null;
};
const axeImpactToPa11yLevel = {
critical: 'error',
serious: 'error',
moderate: 'warning',
minor: 'notice'
};
const pa11yLevelSeverity = {
error: 3,
warning: 2,
notice: 1
};
const isWithinMaxLevel = (level, maxLevel) => {
return pa11yLevelSeverity[level] <= pa11yLevelSeverity[maxLevel];
};
/**
* Choose a Pa11y reporting level corresponding to an axe issue
* @param {AxeImpact} axeImpact Axe's reported impact level
* @param {boolean} issueNeedsReview Whether the issue needs further review
* @param {string} levelCapWhenNeedsReview Cap incomplete issue level: error, warning, notice
* @returns {Pa11yLevel} A Pa11y level
*/
const choosePa11yLevel = (axeImpact, issueNeedsReview, levelCapWhenNeedsReview) => {
let level = axeImpactToPa11yLevel?.[axeImpact] ?? 'error';
if (issueNeedsReview && levelCapWhenNeedsReview && !isWithinMaxLevel(
level,
levelCapWhenNeedsReview
)) {
level = levelCapWhenNeedsReview;
}
return level;
};
/**
* Process an axe issue.
* @param {AxeResult} axeIssue The axe issue
* @param {boolean} needsFurtherReview This axe issue is incomplete and may need review.
* @param {string} levelCapWhenNeedsReview Cap incomplete issue level: error, warning, notice
* @returns {object[]} List of processed issues
* @see https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#results-object
*/
const processIssue = (
{nodes, id, description, help, helpUrl, impact},
needsFurtherReview = false,
levelCapWhenNeedsReview
) => {
return (nodes.length ? nodes : nodesForNodelessIssue())
.map(({target}) => target && selectorToString(target))
.map(selector => selector && window.document.querySelector(selector))
.map(element => ({
element,
code: id,
type: choosePa11yLevel(impact, needsFurtherReview, levelCapWhenNeedsReview),
message: `${help} (${helpUrl})`,
runnerExtras: {
description,
impact,
needsFurtherReview,
help,
helpUrl
}
}));
};
const nodesForNodelessIssue = () => [{
target: null
}];
/**
* Run axe on the page
* @param {Pa11yConfiguration} options Pa11y's configuration
* @returns {Promise<Array<Pa11yResult>>} A promise of axe issues translated for Pa11y
*/
async function runAxeCore({standard, rules, ignore, rootElement, levelCapWhenNeedsReview}) {
const {
violations = [],
incomplete = []
} =
await getBrowserAxe()
.run(
getAxeContext(rootElement),
getAxeOptions({
standard,
rules,
ignore
})
);
return [
...violations.flatMap(issue => processIssue(issue, false, levelCapWhenNeedsReview)),
...incomplete.flatMap(issue => processIssue(issue, true, levelCapWhenNeedsReview))
];
}
return await runAxeCore(options);
};
/**
* Pa11y runner for axe.
* @type {import('../pa11y').Pa11yRunner}
* @see https://www.deque.com/axe/core-documentation/api-documentation/
*/
const runner = {
run,
scripts: [`${axePath}/axe.min.js`],
supports: '^9.0.0 || ^9.0.0-alpha || ^9.0.0-beta'
};
/**
* Export for CommonJS.
* For browsers, runners are currently stringified and inserted directly.
*/
module.exports = runner;