axe-core
Version:
Accessibility engine for automated Web UI testing
683 lines (603 loc) • 17.9 kB
JavaScript
/*global Rule, Check, RuleResult, commons: true */
/*eslint no-unused-vars: 0*/
function getDefaultConfiguration(audit) {
'use strict';
var config;
if (audit) {
config = axe.utils.clone(audit);
// Commons are configured into axe like everything else,
// however because things go funky if we have multiple commons objects
// we're not using the copy of that.
config.commons = audit.commons;
} else {
config = {};
}
config.reporter = config.reporter || null;
config.rules = config.rules || [];
config.checks = config.checks || [];
config.data = { checks: {}, rules: {}, ...config.data };
return config;
}
function unpackToObject(collection, audit, method) {
'use strict';
var i, l;
for (i = 0, l = collection.length; i < l; i++) {
audit[method](collection[i]);
}
}
/**
* Constructor which holds configured rules and information about the document under test
*/
function Audit(audit) {
// defaults
this.brand = 'axe';
this.application = 'axeAPI';
this.tagExclude = ['experimental'];
this.defaultConfig = audit;
this._init();
// A copy of the "default" locale. This will be set if the user
// provides a new locale to `axe.configure()` and used to undo
// changes in `axe.reset()`.
this._defaultLocale = null;
}
/**
* Build and set the previous locale. Will noop if a previous
* locale was already set, as we want the ability to "reset"
* to the default ("first") configuration.
*/
Audit.prototype._setDefaultLocale = function() {
if (this._defaultLocale) {
return;
}
const locale = {
checks: {},
rules: {}
};
// XXX: unable to use `for-of` here, as doing so would
// require us to polyfill `Symbol`.
const checkIDs = Object.keys(this.data.checks);
for (let i = 0; i < checkIDs.length; i++) {
const id = checkIDs[i];
const check = this.data.checks[id];
const { pass, fail, incomplete } = check.messages;
locale.checks[id] = {
pass,
fail,
incomplete
};
}
const ruleIDs = Object.keys(this.data.rules);
for (let i = 0; i < ruleIDs.length; i++) {
const id = ruleIDs[i];
const rule = this.data.rules[id];
const { description, help } = rule;
locale.rules[id] = { description, help };
}
this._defaultLocale = locale;
};
/**
* Reset the locale to the "default".
*/
Audit.prototype._resetLocale = function() {
// If the default locale has not already been set, we can exit early.
const defaultLocale = this._defaultLocale;
if (!defaultLocale) {
return;
}
// Apply the default locale
this.applyLocale(defaultLocale);
};
/**
* Merge two check locales (a, b), favoring `b`.
*
* Both locale `a` and the returned shape resemble:
*
* {
* impact: string,
* messages: {
* pass: string | function,
* fail: string | function,
* incomplete: string | {
* [key: string]: string | function
* }
* }
* }
*
* Locale `b` follows the `axe.CheckLocale` shape and resembles:
*
* {
* pass: string,
* fail: string,
* incomplete: string | { [key: string]: string }
* }
*/
const mergeCheckLocale = (a, b) => {
let { pass, fail } = b;
// If the message(s) are Strings, they have not yet been run
// thru doT (which will return a Function).
if (typeof pass === 'string') {
pass = axe.imports.doT.compile(pass);
}
if (typeof fail === 'string') {
fail = axe.imports.doT.compile(fail);
}
return {
...a,
messages: {
pass: pass || a.messages.pass,
fail: fail || a.messages.fail,
incomplete:
typeof a.messages.incomplete === 'object'
? // TODO: for compleness-sake, we should be running
// incomplete messages thru doT as well. This was
// out-of-scope for runtime localization, but should
// eventually be addressed.
{ ...a.messages.incomplete, ...b.incomplete }
: b.incomplete
}
};
};
/**
* Merge two rule locales (a, b), favoring `b`.
*/
const mergeRuleLocale = (a, b) => {
let { help, description } = b;
// If the message(s) are Strings, they have not yet been run
// thru doT (which will return a Function).
if (typeof help === 'string') {
help = axe.imports.doT.compile(help);
}
if (typeof description === 'string') {
description = axe.imports.doT.compile(description);
}
return {
...a,
help: help || a.help,
description: description || a.description
};
};
/**
* Apply locale for the given `checks`.
*/
Audit.prototype._applyCheckLocale = function(checks) {
const keys = Object.keys(checks);
for (let i = 0; i < keys.length; i++) {
const id = keys[i];
if (!this.data.checks[id]) {
throw new Error(`Locale provided for unknown check: "${id}"`);
}
this.data.checks[id] = mergeCheckLocale(this.data.checks[id], checks[id]);
}
};
/**
* Apply locale for the given `rules`.
*/
Audit.prototype._applyRuleLocale = function(rules) {
const keys = Object.keys(rules);
for (let i = 0; i < keys.length; i++) {
const id = keys[i];
if (!this.data.rules[id]) {
throw new Error(`Locale provided for unknown rule: "${id}"`);
}
this.data.rules[id] = mergeRuleLocale(this.data.rules[id], rules[id]);
}
};
/**
* Apply the given `locale`.
*
* @param {axe.Locale}
*/
Audit.prototype.applyLocale = function(locale) {
this._setDefaultLocale();
if (locale.checks) {
this._applyCheckLocale(locale.checks);
}
if (locale.rules) {
this._applyRuleLocale(locale.rules);
}
};
/**
* Initializes the rules and checks
*/
Audit.prototype._init = function() {
var audit = getDefaultConfiguration(this.defaultConfig);
axe.commons = commons = audit.commons;
this.reporter = audit.reporter;
this.commands = {};
this.rules = [];
this.checks = {};
unpackToObject(audit.rules, this, 'addRule');
unpackToObject(audit.checks, this, 'addCheck');
this.data = {};
this.data.checks = (audit.data && audit.data.checks) || {};
this.data.rules = (audit.data && audit.data.rules) || {};
this.data.failureSummaries =
(audit.data && audit.data.failureSummaries) || {};
this.data.incompleteFallbackMessage =
(audit.data && audit.data.incompleteFallbackMessage) || '';
this._constructHelpUrls(); // create default helpUrls
};
/**
* Adds a new command to the audit
*/
Audit.prototype.registerCommand = function(command) {
'use strict';
this.commands[command.id] = command.callback;
};
/**
* Adds a new rule to the Audit. If a rule with specified ID already exists, it will be overridden
* @param {Object} spec Rule specification object
*/
Audit.prototype.addRule = function(spec) {
'use strict';
if (spec.metadata) {
this.data.rules[spec.id] = spec.metadata;
}
let rule = this.getRule(spec.id);
if (rule) {
rule.configure(spec);
} else {
this.rules.push(new Rule(spec, this));
}
};
/**
* Adds a new check to the Audit. If a Check with specified ID already exists, it will be
* reconfigured
*
* @param {Object} spec Check specification object
*/
Audit.prototype.addCheck = function(spec) {
/*eslint no-eval: 0 */
'use strict';
let metadata = spec.metadata;
if (typeof metadata === 'object') {
this.data.checks[spec.id] = metadata;
// Transform messages into functions:
if (typeof metadata.messages === 'object') {
Object.keys(metadata.messages)
.filter(
prop =>
metadata.messages.hasOwnProperty(prop) &&
typeof metadata.messages[prop] === 'string'
)
.forEach(prop => {
if (metadata.messages[prop].indexOf('function') === 0) {
metadata.messages[prop] = new Function(
'return ' + metadata.messages[prop] + ';'
)();
}
});
}
}
if (this.checks[spec.id]) {
this.checks[spec.id].configure(spec);
} else {
this.checks[spec.id] = new Check(spec);
}
};
/**
* Splits a given array of rules to two, with rules that can be run immediately and one's that are dependent on preloadedAssets
* @method getRulesToRun
* @param {Array<Object>} rules complete list of rules
* @param {Object} context
* @param {Object} options
* @return {Object} out, an object containing two arrays, one being list of rules to run now and list of rules to run later
* @private
*/
function getRulesToRun(rules, context, options) {
// entry object for reduce function below
const base = {
now: [],
later: []
};
// iterate through rules and separate out rules that need to be run now vs later
const splitRules = rules.reduce((out, rule) => {
// ensure rule can run
if (!axe.utils.ruleShouldRun(rule, context, options)) {
return out;
}
// does rule require preload assets - push to later array
if (rule.preload) {
out.later.push(rule);
return out;
}
// default to now array
out.now.push(rule);
// return
return out;
}, base);
// return
return splitRules;
}
/**
* Convenience method, that consturcts a rule `run` function that can be deferred
* @param {Object} rule rule to be deferred
* @param {Object} context context object essential to be passed into rule `run`
* @param {Object} options normalised options to be passed into rule `run`
* @param {Object} assets (optional) preloaded assets to be passed into rule and checks (if the rule is preload dependent)
* @return {Function} a deferrable function for rule
*/
function getDefferedRule(rule, context, options) {
// init performance timer of requested via options
if (options.performanceTimer) {
axe.utils.performanceTimer.mark('mark_rule_start_' + rule.id);
}
return (resolve, reject) => {
// invoke `rule.run`
rule.run(
context,
options,
// resolve callback for rule `run`
ruleResult => {
// resolve
resolve(ruleResult);
},
// reject callback for rule `run`
err => {
// if debug - construct error details
if (!options.debug) {
const errResult = Object.assign(new RuleResult(rule), {
result: axe.constants.CANTTELL,
description: 'An error occured while running this rule',
message: err.message,
stack: err.stack,
error: err,
// Add a serialized reference to the node the rule failed on for easier debugging.
// See https://github.com/dequelabs/axe-core/issues/1317.
errorNode: err.errorNode
});
// resolve
resolve(errResult);
} else {
// reject
reject(err);
}
}
);
};
}
/**
* Runs the Audit; which in turn should call `run` on each rule.
* @async
* @param {Context} context The scope definition/context for analysis (include/exclude)
* @param {Object} options Options object to pass into rules and/or disable rules or checks
* @param {Function} fn Callback function to fire when audit is complete
*/
Audit.prototype.run = function(context, options, resolve, reject) {
'use strict';
this.normalizeOptions(options);
axe._selectCache = [];
// get a list of rules to run NOW vs. LATER (later are preload assets dependent rules)
const allRulesToRun = getRulesToRun(this.rules, context, options);
const runNowRules = allRulesToRun.now;
const runLaterRules = allRulesToRun.later;
// init a NOW queue for rules to run immediately
const nowRulesQueue = axe.utils.queue();
// construct can run NOW rules into NOW queue
runNowRules.forEach(rule => {
nowRulesQueue.defer(getDefferedRule(rule, context, options));
});
// init a PRELOADER queue to start preloading assets
const preloaderQueue = axe.utils.queue();
// defer preload if preload dependent rules exist
if (runLaterRules.length) {
preloaderQueue.defer(resolve => {
// handle both success and fail of preload
// and resolve, to allow to run all checks
axe.utils
.preload(options)
.then(assets => resolve(assets))
.catch(err => {
/**
* Note:
* we do not reject, to allow other (non-preload) rules to `run`
* -> instead we resolve as `undefined`
*/
console.warn(`Couldn't load preload assets: `, err);
resolve(undefined);
});
});
}
// defer now and preload queue to run immediately
const queueForNowRulesAndPreloader = axe.utils.queue();
queueForNowRulesAndPreloader.defer(nowRulesQueue);
queueForNowRulesAndPreloader.defer(preloaderQueue);
// invoke the now queue
queueForNowRulesAndPreloader
.then(nowRulesAndPreloaderResults => {
// interpolate results into separate variables
const assetsFromQueue = nowRulesAndPreloaderResults.pop();
if (assetsFromQueue && assetsFromQueue.length) {
// result is a queue (again), hence the index resolution
// assets is either an object of key value pairs of asset type and values
// eg: cssom: [stylesheets]
// or undefined if preload failed
const assets = assetsFromQueue[0];
// extend context with preloaded assets
if (assets) {
context = {
...context,
...assets
};
}
}
// the reminder of the results are RuleResults
const nowRulesResults = nowRulesAndPreloaderResults[0];
// if there are no rules to run LATER - resolve with rule results
if (!runLaterRules.length) {
// remove the cache
axe._selectCache = undefined;
// resolve
resolve(nowRulesResults.filter(result => !!result));
return;
}
// init a LATER queue for rules that are dependant on preloaded assets
const laterRulesQueue = axe.utils.queue();
runLaterRules.forEach(rule => {
const deferredRule = getDefferedRule(rule, context, options);
laterRulesQueue.defer(deferredRule);
});
// invoke the later queue
laterRulesQueue
.then(laterRuleResults => {
// remove the cache
axe._selectCache = undefined;
// resolve
resolve(
nowRulesResults.concat(laterRuleResults).filter(result => !!result)
);
})
.catch(reject);
})
.catch(reject);
};
/**
* Runs Rule `after` post processing functions
* @param {Array} results Array of RuleResults to postprocess
* @param {Mixed} options Options object to pass into rules and/or disable rules or checks
*/
Audit.prototype.after = function(results, options) {
'use strict';
var rules = this.rules;
return results.map(function(ruleResult) {
var rule = axe.utils.findBy(rules, 'id', ruleResult.id);
if (!rule) {
// If you see this, you're probably running the Mocha tests with the axe extension installed
throw new Error(
'Result for unknown rule. You may be running mismatch axe-core versions'
);
}
return rule.after(ruleResult, options);
});
};
/**
* Get the rule with a given ID
* @param {string}
* @return {Rule}
*/
Audit.prototype.getRule = function(ruleId) {
return this.rules.find(rule => rule.id === ruleId);
};
/**
* Ensure all rules that are expected to run exist
* @throws {Error} If any tag or rule specified in options is unknown
* @param {Object} options Options object
* @return {Object} Validated options object
*/
Audit.prototype.normalizeOptions = function(options) {
'use strict';
var audit = this;
// Validate runOnly
if (typeof options.runOnly === 'object') {
if (Array.isArray(options.runOnly)) {
options.runOnly = {
type: 'tag',
values: options.runOnly
};
}
const only = options.runOnly;
if (only.value && !only.values) {
only.values = only.value;
delete only.value;
}
if (!Array.isArray(only.values) || only.values.length === 0) {
throw new Error('runOnly.values must be a non-empty array');
}
// Check if every value in options.runOnly is a known rule ID
if (['rule', 'rules'].includes(only.type)) {
only.type = 'rule';
only.values.forEach(function(ruleId) {
if (!audit.getRule(ruleId)) {
throw new Error('unknown rule `' + ruleId + '` in options.runOnly');
}
});
// Validate 'tags' (e.g. anything not 'rule')
} else if (['tag', 'tags', undefined].includes(only.type)) {
only.type = 'tag';
const unmatchedTags = audit.rules.reduce((unmatchedTags, rule) => {
return unmatchedTags.length
? unmatchedTags.filter(tag => !rule.tags.includes(tag))
: unmatchedTags;
}, only.values);
if (unmatchedTags.length !== 0) {
axe.log('Could not find tags `' + unmatchedTags.join('`, `') + '`');
}
} else {
throw new Error(`Unknown runOnly type '${only.type}'`);
}
}
if (typeof options.rules === 'object') {
Object.keys(options.rules).forEach(function(ruleId) {
if (!audit.getRule(ruleId)) {
throw new Error('unknown rule `' + ruleId + '` in options.rules');
}
});
}
return options;
};
/*
* Updates the default options and then applies them
* @param {Mixed} options Options object
*/
Audit.prototype.setBranding = function(branding) {
'use strict';
let previous = {
brand: this.brand,
application: this.application
};
if (
branding &&
branding.hasOwnProperty('brand') &&
branding.brand &&
typeof branding.brand === 'string'
) {
this.brand = branding.brand;
}
if (
branding &&
branding.hasOwnProperty('application') &&
branding.application &&
typeof branding.application === 'string'
) {
this.application = branding.application;
}
this._constructHelpUrls(previous);
};
/**
* For all the rules, create the helpUrl and add it to the data for that rule
*/
function getHelpUrl({ brand, application }, ruleId, version) {
return (
axe.constants.helpUrlBase +
brand +
'/' +
(version || axe.version.substring(0, axe.version.lastIndexOf('.'))) +
'/' +
ruleId +
'?application=' +
application
);
}
Audit.prototype._constructHelpUrls = function(previous = null) {
var version = (axe.version.match(/^[1-9][0-9]*\.[0-9]+/) || ['x.y'])[0];
this.rules.forEach(rule => {
if (!this.data.rules[rule.id]) {
this.data.rules[rule.id] = {};
}
let metaData = this.data.rules[rule.id];
if (
typeof metaData.helpUrl !== 'string' ||
(previous && metaData.helpUrl === getHelpUrl(previous, rule.id, version))
) {
metaData.helpUrl = getHelpUrl(this, rule.id, version);
}
});
};
/**
* Reset the default rules, checks and meta data
*/
Audit.prototype.resetRulesAndChecks = function() {
'use strict';
this._init();
this._resetLocale();
};