diff --git a/package.json b/package.json index 9f6365da..c335ef75 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "dist" ], "dependencies": { - "axe-core": "^2.3.1", + "axe-core": "^2.4.2", "env-paths": "^1.0.0", "express": "^4.15.5", "express-easy-zip": "^1.1.4", diff --git a/src/checker/checker-nightmare.js b/src/checker/checker-nightmare.js index 119a2292..4c7c154e 100644 --- a/src/checker/checker-nightmare.js +++ b/src/checker/checker-nightmare.js @@ -18,6 +18,12 @@ if (!fs.existsSync(PATH_TO_H5O)) { throw new Error('Can’t find h5o'); } +const PATH_TO_AXE_PATCH = path.join(__dirname, '../scripts/axe-patch.js'); +if (!fs.existsSync(PATH_TO_AXE_PATCH)) { + winston.verbose(PATH_TO_AXE_PATCH); + throw new Error('Can’t find axe-patch script'); +} + const PATH_TO_ACE_AXE = path.join(__dirname, '../scripts/ace-axe.js'); if (!fs.existsSync(PATH_TO_ACE_AXE)) { winston.verbose(PATH_TO_ACE_AXE); @@ -49,6 +55,7 @@ function checkSingle(spineItem, epub, nightmare) { .goto(spineItem.url) .inject('js', PATH_TO_AXE) .inject('js', PATH_TO_H5O) + .inject('js', PATH_TO_AXE_PATCH) .inject('js', PATH_TO_ACE_AXE) .inject('js', PATH_TO_ACE_EXTRACTION) .wait(50) diff --git a/src/scripts/axe-patch.js b/src/scripts/axe-patch.js new file mode 100644 index 00000000..f79bff66 --- /dev/null +++ b/src/scripts/axe-patch.js @@ -0,0 +1,235 @@ +/** + * Monkey-patch for aXe v2.4.2 + * + * Copyright (c) 2017 Deque Systems, Inc. + * + * Your use of this Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * This entire copyright notice must appear in every copy of this file you + * distribute or in any file that contains substantial portions of this source + * code. + */ + +(function axePatch(window) { +const axe = window.axe; +const escapeSelector = axe.utils.escapeSelector; + +function isUncommonClassName (className) { + return ![ + 'focus', 'hover', + 'hidden', 'visible', + 'dirty', 'touched', 'valid', 'disable', + 'enable', 'active', 'col-' + ].find(str => className.includes(str)); +} + +function getDistinctClassList (elm) { + if (!elm.classList || elm.classList.length === 0) { + return []; + } + + const siblings = elm.parentNode && Array.from(elm.parentNode.children || '') || []; + return siblings.reduce((classList, childElm) => { + if (elm === childElm) { + return classList; + } else { + return classList.filter(classItem => { + return !childElm.classList.contains(classItem); + }); + } + }, Array.from(elm.classList).filter(isUncommonClassName)); +} + +const commonNodes = [ + 'div', 'span', 'p', + 'b', 'i', 'u', 'strong', 'em', + 'h2', 'h3' +]; + +function getNthChildString (elm, selector) { + const siblings = elm.parentNode && Array.from(elm.parentNode.children || '') || []; + const hasMatchingSiblings = siblings.find(sibling => ( + sibling !== elm && + axe.utils.matchesSelector(sibling, selector) + )); + if (hasMatchingSiblings) { + const nthChild = 1 + siblings.indexOf(elm); + return ':nth-child(' + nthChild + ')'; + } else { + return ''; + } +} + +const createSelector = { + // Get ID properties + getElmId (elm) { + if (!elm.getAttribute('id')) { + return; + } + const id = '#' + escapeSelector(elm.getAttribute('id') || ''); + if ( + // Don't include youtube's uid values, they change on reload + !id.match(/player_uid_/) && + // Don't include IDs that occur more then once on the page + document.querySelectorAll(id).length === 1 + ) { + return id; + } + }, + // Get custom element name + getCustomElm (elm, { isCustomElm, nodeName }) { + if (isCustomElm) { + return nodeName; + } + }, + + // Get ARIA role + getElmRoleProp (elm) { + if (elm.hasAttribute('role')) { + return '[role="' + escapeSelector(elm.getAttribute('role')) +'"]'; + } + }, + // Get uncommon node names + getUncommonElm (elm, { isCommonElm, isCustomElm, nodeName }) { + if (!isCommonElm && !isCustomElm) { + nodeName = escapeSelector(nodeName); + // Add [type] if nodeName is an input element + if (nodeName === 'input' && elm.hasAttribute('type')) { + nodeName += '[type="' + elm.type + '"]'; + } + return nodeName; + } + }, + // Has a name property, but no ID (Think input fields) + getElmNameProp (elm) { + if (!elm.hasAttribute('id') && elm.name) { + return '[name="' + escapeSelector(elm.name) + '"]'; + } + }, + // Get any distinct classes (as long as there aren't more then 3 of them) + getDistinctClass (elm, { distinctClassList }) { + if (distinctClassList.length > 0 && distinctClassList.length < 3) { + return '.' + distinctClassList.map(escapeSelector).join('.'); + } + }, + // Get a selector that uses src/href props + getFileRefProp (elm) { + let attr; + if (elm.hasAttribute('href')) { + attr = 'href'; + } else if (elm.hasAttribute('src')) { + attr = 'src'; + } else { + return; + } + const friendlyUriEnd = axe.utils.getFriendlyUriEnd(elm.getAttribute(attr)); + if (friendlyUriEnd) { + return '[' + attr + '$="' + encodeURI(friendlyUriEnd) + '"]'; + } + }, + // Get common node names + getCommonName (elm, { nodeName, isCommonElm }) { + if (isCommonElm) { + return nodeName; + } + } +}; + +/** + * Get an array of features (as CSS selectors) that describe an element + * + * By going down the list of most to least prominent element features, + * we attempt to find those features that a dev is most likely to + * recognize the element by (IDs, aria roles, custom element names, etc.) + */ +function getElmFeatures (elm, featureCount) { + const nodeName = elm.localName.toLowerCase(); + const classList = Array.from(elm.classList) || []; + // Collect some props we need to build the selector + const props = { + nodeName, + classList, + isCustomElm: nodeName.includes('-'), + isCommonElm: commonNodes.includes(nodeName), + distinctClassList: getDistinctClassList(elm) + }; + + return [ + // go through feature selectors in order of priority + createSelector.getCustomElm, + createSelector.getElmRoleProp, + createSelector.getUncommonElm, + createSelector.getElmNameProp, + createSelector.getDistinctClass, + createSelector.getFileRefProp, + createSelector.getCommonName + ].reduce((features, func) => { + // As long as we haven't met our count, keep looking for features + if (features.length === featureCount) { + return features; + } + + const feature = func(elm, props); + if (feature) { + if (!feature[0].match(/[a-z]/)) { + features.push(feature); + } else { + features.unshift(feature); + } + } + return features; + }, []); +} + +/** + * Gets a unique CSS selector + * @param {HTMLElement} node The element to get the selector for + * @return {String} Unique CSS selector for the node + */ +axe.utils.getSelector = function createUniqueSelector (elm, options = {}) { + //jshint maxstatements: 19 + if (!elm) { + return ''; + } + let selector, addParent; + let { isUnique = false } = options; + const idSelector = createSelector.getElmId(elm); + const { + featureCount = 2, + minDepth = 1, + toRoot = false, + childSelectors = [] + } = options; + + if (idSelector) { + selector = idSelector; + isUnique = true; + + } else { + selector = getElmFeatures(elm, featureCount).join(''); + selector += getNthChildString(elm, selector); + isUnique = options.isUnique || document.querySelectorAll(selector).length === 1; + + // For the odd case that document doesn't have a unique selector + if (!isUnique && elm === document.documentElement) { + selector += ':root'; + } + addParent = (minDepth !== 0 || !isUnique); + } + + const selectorParts = [selector, ...childSelectors]; + + if (elm.parentElement && (toRoot || addParent)) { + return createUniqueSelector(elm.parentNode, { + toRoot, isUnique, + childSelectors: selectorParts, + featureCount: 1, + minDepth: minDepth -1 + }); + } else { + return selectorParts.join(' > '); + } +}; +}(window)); diff --git a/tests/__tests__/regression.test.js b/tests/__tests__/regression.test.js index eaa49128..71a97649 100644 --- a/tests/__tests__/regression.test.js +++ b/tests/__tests__/regression.test.js @@ -41,7 +41,12 @@ test('issue #49: multiple \'dc:title\' elements', async () => { expect(report['earl:result']['earl:outcome']).toEqual('pass'); }); -test('issue #53: XXX', async () => { +test('issue #53: this.json.data.images.forEach is not a function', async () => { const report = await ace('../data/issue-53'); expect(report['earl:result']['earl:outcome']).toEqual('pass'); }); + +test('issue #57: Failed to execute \'matches\' on \'Element\': \'m:annotation-xml\'', async () => { + const report = await ace('../data/issue-57'); + expect(report['earl:result']['earl:outcome']).toEqual('pass'); +}); diff --git a/tests/data/issue-57/EPUB/content_001.xhtml b/tests/data/issue-57/EPUB/content_001.xhtml new file mode 100644 index 00000000..cac9ef4b --- /dev/null +++ b/tests/data/issue-57/EPUB/content_001.xhtml @@ -0,0 +1,17 @@ + + +Minimal EPUB + + +

Loomings

+

Call me Ishmael.

+ + + x + + x + + + + + diff --git a/tests/data/issue-57/EPUB/nav.xhtml b/tests/data/issue-57/EPUB/nav.xhtml new file mode 100644 index 00000000..1d538c19 --- /dev/null +++ b/tests/data/issue-57/EPUB/nav.xhtml @@ -0,0 +1,12 @@ + + +Minimal Nav + + + + + diff --git a/tests/data/issue-57/EPUB/package.opf b/tests/data/issue-57/EPUB/package.opf new file mode 100644 index 00000000..18bb33fc --- /dev/null +++ b/tests/data/issue-57/EPUB/package.opf @@ -0,0 +1,23 @@ + + + + Minimal EPUB 3.0 + en + NOID + 2017-01-01T00:00:01Z + structuralNavigation + everything OK! + noFlashingHazard + noSoundHazard + noMotionSimulationHazard + textual + textual + + + + + + + + + diff --git a/tests/data/issue-57/META-INF/container.xml b/tests/data/issue-57/META-INF/container.xml new file mode 100644 index 00000000..2cf00654 --- /dev/null +++ b/tests/data/issue-57/META-INF/container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/data/issue-57/mimetype b/tests/data/issue-57/mimetype new file mode 100644 index 00000000..57ef03f2 --- /dev/null +++ b/tests/data/issue-57/mimetype @@ -0,0 +1 @@ +application/epub+zip \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c2df50fd..1353cbac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -250,9 +250,9 @@ aws4@^1.2.1, aws4@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" -axe-core@^2.3.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-2.4.1.tgz#55b6ceaa847cb1eaef5d559b6c41035dc3e07dbf" +axe-core@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-2.4.2.tgz#3156d914051ea32597e584071d9d653b66d1579b" babel-cli@^6.26.0: version "6.26.0"