/*global Rule, Check, RuleResult, commons: true */ const dotRegex = /\{\{.+?\}\}/g; /*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.lang = 'en'; 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: {}, failureSummaries: {}, incompleteFallbackMessage: '', lang: this.lang }; // 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 }; } const failureSummaries = Object.keys(this.data.failureSummaries); for (let i = 0; i < failureSummaries.length; i++) { const type = failureSummaries[i]; const failureSummary = this.data.failureSummaries[type]; const { failureMessage } = failureSummary; locale.failureSummaries[type] = { failureMessage }; } locale.incompleteFallbackMessage = this.data.incompleteFallbackMessage; 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' && dotRegex.test(pass)) { pass = axe.imports.doT.compile(pass); } if (typeof fail === 'string' && dotRegex.test(fail)) { 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' && dotRegex.test(help)) { help = axe.imports.doT.compile(help); } if (typeof description === 'string' && dotRegex.test(description)) { description = axe.imports.doT.compile(description); } return { ...a, help: help || a.help, description: description || a.description }; }; /** * Merge two failure messages (a, b), favoring `b`. */ const mergeFailureMessage = (a, b) => { let { failureMessage } = b; // If the message(s) are Strings, they have not yet been run // thru doT (which will return a Function). if (typeof failureMessage === 'string' && dotRegex.test(failureMessage)) { failureMessage = axe.imports.doT.compile(failureMessage); } return { ...a, failureMessage: failureMessage || a.failureMessage }; }; /** * Merge two incomplete fallback messages (a, b), favoring `b`. */ const mergeFallbackMessage = (a, b) => { if (typeof b === 'string' && dotRegex.test(b)) { b = axe.imports.doT.compile(b); } return b || a; }; /** * 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 locale for the given failureMessage */ Audit.prototype._applyFailureSummaries = function(messages) { const keys = Object.keys(messages); for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (!this.data.failureSummaries[key]) { throw new Error(`Locale provided for unknown failureMessage: "${key}"`); } this.data.failureSummaries[key] = mergeFailureMessage( this.data.failureSummaries[key], messages[key] ); } }; /** * 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); } if (locale.failureSummaries) { this._applyFailureSummaries(locale.failureSummaries, 'failureSummaries'); } if (locale.incompleteFallbackMessage) { this.data.incompleteFallbackMessage = mergeFallbackMessage( this.data.incompleteFallbackMessage, locale.incompleteFallbackMessage ); } if (locale.lang) { this.lang = locale.lang; } }; /** * Initializes the rules and checks */ Audit.prototype._init = function() { var audit = getDefaultConfiguration(this.defaultConfig); axe.commons = commons = audit.commons; this.lang = audit.lang || 'en'; 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} 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; const tags = []; const ruleIds = []; audit.rules.forEach(rule => { ruleIds.push(rule.id); rule.tags.forEach(tag => { if (!tags.includes(tag)) { tags.push(tag); } }); }); // Validate runOnly if (typeof options.runOnly === 'object') { if (Array.isArray(options.runOnly)) { const hasTag = options.runOnly.find(value => tags.includes(value)); const hasRule = options.runOnly.find(value => ruleIds.includes(value)); if (hasTag && hasRule) { throw new Error('runOnly cannot be both rules and tags'); } if (hasRule) { options.runOnly = { type: 'rule', values: options.runOnly }; } else { 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 (!ruleIds.includes(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 = only.values.filter(tag => !tags.includes(tag)); 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 (!ruleIds.includes(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, lang }, ruleId, version) { return ( axe.constants.helpUrlBase + brand + '/' + (version || axe.version.substring(0, axe.version.lastIndexOf('.'))) + '/' + ruleId + '?application=' + encodeURIComponent(application) + (lang && lang !== 'en' ? '&lang=' + encodeURIComponent(lang) : '') ); } 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(); };