diff --git a/lib/core/public/configure.js b/lib/core/public/configure.js index 0915c981e..499bb6d6a 100644 --- a/lib/core/public/configure.js +++ b/lib/core/public/configure.js @@ -9,6 +9,16 @@ function configure(spec) { throw new Error('No audit configured'); } + // [a11y-critical]: configure() reports success by not throwing and returns + // nothing, so a caller cannot tell a completed registration from one that was + // later discarded (see load.js — a second axe evaluation replaces _audit). + // Record what this call registered so that is answerable after the fact. + const configureLog = { + ruleCountBefore: audit.rules.length, + rulesIn: Array.isArray(spec.rules) ? spec.rules.length : 0, + checksIn: Array.isArray(spec.checks) ? spec.checks.length : 0 + }; + if (spec.axeVersion || spec.ver) { const specVersion = spec.axeVersion || spec.ver; if (!/^\d+\.\d+\.\d+(-canary)?/.test(specVersion)) { @@ -139,6 +149,12 @@ function configure(spec) { if (spec.crossOriginDenylist) { audit.setCrossOriginDenylist(spec.crossOriginDenylist); } + + // [a11y-critical]: completes the record started above. audit.generation lets a + // caller detect that the audit it configured is no longer the live one. + configureLog.ruleCountAfter = audit.rules.length; + configureLog.generation = audit.generation; + audit.lastConfigure = configureLog; } export default configure; diff --git a/lib/core/public/load.js b/lib/core/public/load.js index ea69f3b69..c599e04bf 100644 --- a/lib/core/public/load.js +++ b/lib/core/public/load.js @@ -12,7 +12,19 @@ import mergeErrors from '../utils/merge-errors'; * @private */ export default function load(audit) { + // [a11y-critical]: this discards every prior axe.configure() with no error — + // the built axe.js calls _load on evaluation, so evaluating axe twice in one + // context silently resets the registry to stock. Carry a generation counter so + // a caller can tell its configured audit was replaced out from under it. + const previous = axe._audit; axe._audit = new Audit(audit); + axe._audit.generation = previous ? (previous.generation || 0) + 1 : 0; + if (previous && typeof axe.log === 'function') { + axe.log( + 'axe._load replaced an existing audit; previously configured rules and checks are gone', + { generation: axe._audit.generation } + ); + } } function runCommand(data, keepalive, callback) { diff --git a/lib/core/utils/normalize-run-options.js b/lib/core/utils/normalize-run-options.js index 1c65d518c..d163902bd 100644 --- a/lib/core/utils/normalize-run-options.js +++ b/lib/core/utils/normalize-run-options.js @@ -1,6 +1,6 @@ /** * Ensure all rules that are expected to run exist - * @throws {Error} If any tag or rule specified in options is unknown + * @throws {Error} If any rule specified in options.runOnly is unknown * @param {Object} options Options object * @return {Object} Validated options object */ @@ -69,11 +69,13 @@ export default function normalizeRunOptions(options = {}) { } } if (typeof options.rules === 'object') { - Object.keys(options.rules).forEach(ruleId => { - if (!ruleIds.includes(ruleId)) { - throw new Error('unknown rule `' + ruleId + '` in options.rules'); - } - }); + // [a11y-critical]: log unknown ids in options.rules instead of throwing + const unmatchedRules = Object.keys(options.rules).filter( + ruleId => !ruleIds.includes(ruleId) + ); + if (unmatchedRules.length !== 0) { + axe.log('Could not find rules `' + unmatchedRules.join('`, `') + '`'); + } } return options; } diff --git a/test/core/utils/normalize-run-options.js b/test/core/utils/normalize-run-options.js index 67b472d83..daffced4c 100644 --- a/test/core/utils/normalize-run-options.js +++ b/test/core/utils/normalize-run-options.js @@ -213,8 +213,21 @@ describe('axe.utils.normalizeRunOptions', () => { }); }); - it('throws an error when option.rules has an unknown rule', () => { - assert.throws(() => { + it('logs an issue when option.rules has an unknown rule', () => { + let message = ''; + axe._setLogger(m => { + message = m; + }); + axe.utils.normalizeRunOptions({ + rules: { + fakeRule: { enabled: false } + } + }); + assert.include(message, 'Could not find rules'); + }); + + it('does not throw when option.rules has an unknown rule', () => { + assert.doesNotThrow(() => { axe.utils.normalizeRunOptions({ rules: { fakeRule: { enabled: false } @@ -223,6 +236,16 @@ describe('axe.utils.normalizeRunOptions', () => { }); }); + it('keeps known rules in options.rules when another id is unknown', () => { + const options = axe.utils.normalizeRunOptions({ + rules: { + 'color-contrast': { enabled: false }, + fakeRule: { enabled: false } + } + }); + assert.deepEqual(options.rules['color-contrast'], { enabled: false }); + }); + it('logs an issue when a tag is unknown', () => { let message = ''; axe._setLogger(m => {