From 737c81c1062c67277bfa627479a1d42657546de9 Mon Sep 17 00:00:00 2001 From: Dylan Barrell Date: Sun, 11 Feb 2018 12:37:13 -0500 Subject: [PATCH] fix(perf): Improve getSelector performance --- lib/core/base/audit.js | 1 - lib/core/public/run.js | 2 + lib/core/utils/collect-results-from-frames.js | 6 +- lib/core/utils/get-selector.js | 481 +++++++++++------- test/core/base/audit.js | 13 - test/core/public/run-rules.js | 4 + test/core/public/run.js | 15 + .../core/utils/collect-results-from-frames.js | 168 +++--- test/core/utils/dq-element.js | 4 + test/core/utils/get-selector.js | 440 ++++++++++++---- test/core/utils/send-command-to-frame.js | 14 +- 11 files changed, 777 insertions(+), 371 deletions(-) diff --git a/lib/core/base/audit.js b/lib/core/base/audit.js index e371e55195..23e8ccf973 100644 --- a/lib/core/base/audit.js +++ b/lib/core/base/audit.js @@ -180,7 +180,6 @@ Audit.prototype.run = function (context, options, resolve, reject) { } }); q.then(function (results) { - axe._tree = undefined; // empty the tree axe._selectCache = undefined; // remove the cache resolve(results.filter(function (result) { return !!result; })); }).catch(reject); diff --git a/lib/core/public/run.js b/lib/core/public/run.js index 7439624204..aedb19b718 100644 --- a/lib/core/public/run.js +++ b/lib/core/public/run.js @@ -127,6 +127,8 @@ axe.run = function (context, options, callback) { try { let reporter = axe.getReporter(options.reporter); let results = reporter(rawResults, options, respond); + axe._selectorData = undefined; + axe._tree = undefined; if (results !== undefined) { respond(results); } diff --git a/lib/core/utils/collect-results-from-frames.js b/lib/core/utils/collect-results-from-frames.js index 3f000fa1de..edd418c872 100644 --- a/lib/core/utils/collect-results-from-frames.js +++ b/lib/core/utils/collect-results-from-frames.js @@ -1,7 +1,11 @@ function err(message, node) { 'use strict'; - return new Error(message + ': ' + axe.utils.getSelector(node)); + var selector; + if (axe._tree) { + selector = axe.utils.getSelector(node); + } + return new Error(message + ': ' + (selector || node)); } /** diff --git a/lib/core/utils/get-selector.js b/lib/core/utils/get-selector.js index 03b247690a..32932c16b2 100644 --- a/lib/core/utils/get-selector.js +++ b/lib/core/utils/get-selector.js @@ -1,38 +1,162 @@ const escapeSelector = axe.utils.escapeSelector; let isXHTML; +const ignoredAttributes = [ + 'class', 'style', 'id', + 'selected', 'checked', + 'disabled', 'tabindex', + 'aria-checked', 'aria-selected', + 'aria-invalid', 'aria-activedescendant', + 'aria-busy', 'aria-disabled', 'aria-expanded', + 'aria-grabbed', 'aria-pressed', 'aria-valuenow' + ]; +const MAXATTRIBUTELENGTH = 31; -function isUncommonClassName (className) { - return ![ - 'focus', 'hover', - 'hidden', 'visible', - 'dirty', 'touched', 'valid', 'disable', - 'enable', 'active', 'col-' - ].find(str => className.includes(str)); +/** + * get the attribute name and value as a string + * @param {Element} node The element that has the attribute + * @param {Attribute} at The attribute + * @return {String} + */ +function getAttributeNameValue(node, at) { + const name = at.name; + let atnv; + + if (name.indexOf('href') !== -1 || name.indexOf('src') !== -1) { + let value = encodeURI(axe.utils.getFriendlyUriEnd(node.getAttribute(name))); + if (value) { + atnv = escapeSelector(at.name) + '$="' + value + '"'; + } else { + return; + } + } else { + atnv = escapeSelector(name) + '="' + escapeSelector(at.value) + '"'; + } + return atnv; } -function getDistinctClassList (elm) { - if (!elm.classList || elm.classList.length === 0) { - return []; - } +function countSort(a, b) { + return (a.count < b.count) ? -1 : (a.count === b.count) ? 0 : 1; +} - 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)); +/** + * Filter the attributes + * @param {Attribute} The potential attribute + * @return {Boolean} Whether to include or exclude + */ +function filterAttributes(at) { + return !ignoredAttributes.includes(at.name) && at.name.indexOf(':') === -1 && + (!at.value || at.value.length < MAXATTRIBUTELENGTH); } -const commonNodes = [ - 'div', 'span', 'p', - 'b', 'i', 'u', 'strong', 'em', - 'h2', 'h3' -]; +/** + * Calculate the statistics for the classes, attributes and tags on the page, using + * the virtual DOM tree + * @param {Object} domTree The root node of the virtual DOM tree + * @returns {Object} The statistics consisting of three maps, one for classes, + * one for tags and one for attributes. The map values are + * the counts for how many elements with that feature exist + */ +axe.utils.getSelectorData = function (domTree) { + // jshint maxstatements:22 + // jshint loopfunc:true + + // Initialize the return structure with the three maps + let data = { + classes: {}, + tags: {}, + attributes: {} + }; + + domTree = Array.isArray(domTree) ? domTree : [domTree]; + let currentLevel = domTree.slice(); + let stack = []; + while (currentLevel.length) { + let current = currentLevel.pop(); + let node = current.actualNode; + + if (!!node.querySelectorAll) { // ignore #text nodes + + // count the tag + let tag = node.nodeName; + if (data.tags[tag]) { + data.tags[tag]++; + } else { + data.tags[tag] = 1; + } + + // count all the classes + if (node.classList) { + Array.from(node.classList).forEach((cl) => { + const ind = escapeSelector(cl); + if (data.classes[ind]) { + data.classes[ind]++; + } else { + data.classes[ind] = 1; + } + }); + } + + // count all the filtered attributes + if (node.attributes) { + Array.from(node.attributes).filter(filterAttributes).forEach((at) => { + let atnv = getAttributeNameValue(node, at); + if (atnv) { + if (data.attributes[atnv]) { + data.attributes[atnv]++; + } else { + data.attributes[atnv] = 1; + } + } + }); + } + } + if (current.children.length) { + // "recurse" + stack.push(currentLevel); + currentLevel = current.children.slice(); + } + while (!currentLevel.length && stack.length) { + currentLevel = stack.pop(); + } + } + return data; +}; +/** + * Given a node and the statistics on class frequency on the page, + * return all its uncommon class data sorted in order of decreasing uniqueness + * @param {Element} node The node + * @param {Object} classData The map of classes to counts + * @return {Array} The sorted array of uncommon class data + */ +function uncommonClasses(node, selectorData) { + // jshint loopfunc:true + let retVal = []; + let classData = selectorData.classes; + let tagData = selectorData.tags; + + if (node.classList) { + Array.from(node.classList).forEach((cl) => { + let ind = escapeSelector(cl); + if (classData[ind] < tagData[node.nodeName]) { + retVal.push({ + name: ind, + count: classData[ind], + species: 'class' + }); + } + }); + } + return retVal.sort(countSort); +} + +/** + * Given an element and a selector that finds that element (but possibly other sibling elements) + * return the :nth-child(n) pseudo selector that uniquely finds the node within its siblings + * @param {Element} elm The Element + * @param {String} selector The selector + * @return {String} The nth-child selector + */ function getNthChildString (elm, selector) { const siblings = elm.parentNode && Array.from(elm.parentNode.children || '') || []; const hasMatchingSiblings = siblings.find(sibling => ( @@ -47,181 +171,186 @@ function getNthChildString (elm, selector) { } } -const createSelector = { - // Get ID properties - getElmId (elm) { - if (!elm.getAttribute('id')) { - return; - } - let doc = (elm.getRootNode && elm.getRootNode()) || document; - 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 - doc.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) { - // 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 ID selector + */ +function getElmId (elm) { + if (!elm.getAttribute('id')) { + return; } -}; + let doc = (elm.getRootNode && elm.getRootNode()) || document; + 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 + doc.querySelectorAll(id).length === 1 + ) { + return id; + } +} /** - * Get an array of features (as CSS selectors) that describe an element + * Return the base CSS selector for a given 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) { + * @param {HTMLElement} elm The element to get the selector for + * @return {String | Array[String]} Base CSS selector for the node +*/ +function getBaseSelector(elm) { if (typeof isXHTML === 'undefined') { isXHTML = axe.utils.isXHTML(document); } - const nodeName = escapeSelector(isXHTML? - elm.localName - :elm.nodeName.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; - } + return escapeSelector(isXHTML ? elm.localName : elm.nodeName.toLowerCase()); +} - const feature = func(elm, props); - if (feature) { - if (!feature[0].match(/[a-z]/)) { - features.push(feature); - } else { - features.unshift(feature); +/** + * Given a node and the statistics on attribute frequency on the page, + * return all its uncommon attribute data sorted in order of decreasing uniqueness + * @param {Element} node The node + * @param {Object} attData The map of attributes to counts + * @return {Array} The sorted array of uncommon attribute data + */ +function uncommonAttributes(node, selectorData) { + let retVal = []; + let attData = selectorData.attributes; + let tagData = selectorData.tags; + + if (node.attributes) { + Array.from(node.attributes).filter(filterAttributes).forEach((at) => { + const atnv = getAttributeNameValue(node, at); + + if (atnv && attData[atnv] < tagData[node.nodeName]) { + retVal.push({ + name: atnv, + count: attData[atnv], + species: 'attribute' + }); } - } - return features; - }, []); + }); + } + return retVal.sort(countSort); } -function generateSelector (elm, options, doc) { - //jshint maxstatements: 19 - let selector, addParent; - let { isUnique = false } = options; - const idSelector = createSelector.getElmId(elm); - const { - featureCount = 2, - minDepth = 1, - toRoot = false, - childSelectors = [] - } = options; +/** + * generates a selector fragment for an element based on the statistics of the page in + * which the element exists. This function takes into account the fact that selectors that + * use classes and tags are much faster than universal selectors. It also tries to use a + * unique class selector before a unique attribute selector (with the tag), followed by + * a selector made up of the three least common features statistically. A feature will + * also only be used if it is less common than the tag of the element itself. + * + * @param {Element} elm The element for which to generate a selector + * @param {Object} options Options for how to generate the selector + * @param {RootNode} doc The root node of the document or document fragment + * @returns {String} The selector + */ - if (idSelector) { - selector = idSelector; - isUnique = true; +function getThreeLeastCommonFeatures(elm, selectorData) { + let selector = ''; + let features; + let clss = uncommonClasses(elm, selectorData); + let atts = uncommonAttributes(elm, selectorData); + if (clss.length && clss[0].count === 1) { + // only use the unique class + features = [clss[0]]; + } else if (atts.length && atts[0].count === 1) { + // only use the unique attribute value + features = [atts[0]]; + // if no class, add the tag + selector = getBaseSelector(elm); } else { - selector = getElmFeatures(elm, featureCount).join(''); - selector += getNthChildString(elm, selector); - isUnique = options.isUnique || doc.querySelectorAll(selector).length === 1; + features = clss.concat(atts); + // sort by least common + features.sort(countSort); - // For the odd case that document doesn't have a unique selector - if (!isUnique && elm === document.documentElement) { - // todo: figure out what to do for shadow DOM - selector += ':root'; + // select three least common features + features = features.slice(0, 3); + + // if no class, add the tag + if (!features.some((feat) => { return feat.species === 'class'; })) { + // has no class + selector = getBaseSelector(elm); + } else { + // put the classes at the front of the selector + features.sort((a, b) => { + return (a.species !== b.species && a.species === 'class') ? -1 : (a.species === b.species) ? 0 : 1; + }); } - addParent = (minDepth !== 0 || !isUnique); } - const selectorParts = [selector, ...childSelectors]; + // construct the return value + return selector += features.reduce((val, feat) => { + switch(feat.species) { + case 'class': + return val + '.' + feat.name; + case 'attribute': + return val + '[' + feat.name + ']'; + } + return val; // should never happen + }, ''); +} - if (elm.parentElement && elm.parentElement.nodeType !== 11 && - (toRoot || addParent)) { - return generateSelector(elm.parentNode, { - toRoot, isUnique, - childSelectors: selectorParts, - featureCount: 1, - minDepth: minDepth -1 - }, doc); - } else { - return selectorParts.join(' > '); +/** + * generates a single selector for an element + * @param {Element} elm The element for which to generate a selector + * @param {Object} options Options for how to generate the selector + * @param {RootNode} doc The root node of the document or document fragment + * @returns {String} The selector + */ + +function generateSelector (elm, options, doc) { + //jshint maxstatements:21 + //jshint loopfunc:true + if (!axe._selectorData) { + axe._selectorData = axe.utils.getSelectorData(axe._tree); + } + const { + toRoot = false + } = options; + let selector; + let similar; + + /** + * Try to find a unique selector by filtering out all the clashing + * nodes by adding ancestor selectors iteratively. + * This loop is much faster than recursing and using querySelectorAll + */ + do { + let features = getElmId(elm); + if (!features) { + features = getThreeLeastCommonFeatures(elm, axe._selectorData); + features += getNthChildString(elm, features); + } + if (selector) { + selector = features + ' > ' + selector; + } else { + selector = features; + } + if (!similar) { + similar = Array.from(doc.querySelectorAll(selector)); + } else { + similar = similar.filter((item) => { + return axe.utils.matchesSelector(item, selector); + }); + } + elm = elm.parentElement; + } while ((similar.length > 1 || toRoot) && elm && elm.nodeType !== 11); + + if (similar.length === 1) { + return selector; + } else if (selector.indexOf(' > ') !== -1) { + // For the odd case that document doesn't have a unique selector + return ':root' + selector.substring(selector.indexOf(' > ')); } + return ':root'; } /** * Gets a unique CSS selector - * @param {HTMLElement} node The element to get the selector for + * @param {HTMLElement} node The element to get the selector for * @param {Object} optional options - * @return {String | Array[String]} Unique CSS selector for the node + * @return {String | Array[String]} Unique CSS selector for the node */ axe.utils.getSelector = function createUniqueSelector (elm, options = {}) { if (!elm) { diff --git a/test/core/base/audit.js b/test/core/base/audit.js index 2e054379cd..3c78a18b8f 100644 --- a/test/core/base/audit.js +++ b/test/core/base/audit.js @@ -499,19 +499,6 @@ describe('Audit', function () { done(); }, isNotCalled); }); - it('should clear axe._tree', function (done) { - var thing = 'honey badger'; - axe.utils.getFlattenedTree = function () { - return thing; - }; - a.run({ include: [document] }, { - rules: {} - }, function () { - assert.isTrue(typeof axe._tree === 'undefined'); - axe.utils.getFlattenedTree = getFlattenedTree; - done(); - }, isNotCalled); - }); it('should assign an empty array to axe._selectCache', function (done) { var saved = axe.utils.ruleShouldRun; axe.utils.ruleShouldRun = function () { diff --git a/test/core/public/run-rules.js b/test/core/public/run-rules.js index bf376e3bb9..394e03bad1 100644 --- a/test/core/public/run-rules.js +++ b/test/core/public/run-rules.js @@ -72,6 +72,7 @@ describe('runRules', function () { frame.addEventListener('load', function () { setTimeout(function () { + axe._tree = axe.utils.getFlattenedTree(document); runRules(document, {}, function (r) { assert.lengthOf(r[0].passes, 3); done(); @@ -96,6 +97,7 @@ describe('runRules', function () { var frame = document.createElement('iframe'); frame.addEventListener('load', function () { setTimeout(function () { + axe._tree = axe.utils.getFlattenedTree(document); runRules(document, {}, function (r) { var nodes = r[0].passes.map(function (detail) { return detail.node.selector; @@ -154,6 +156,7 @@ describe('runRules', function () { var div = document.createElement('div'); fixture.appendChild(div); + axe._tree = axe.utils.getFlattenedTree(document); runRules('#fixture', {}, function (results) { assert.deepEqual(JSON.parse(JSON.stringify(results)), [{ id: 'div#target', @@ -229,6 +232,7 @@ describe('runRules', function () { }); iframeReady('../mock/frames/context.html', fixture, 'context-test', function () { + axe._tree = axe.utils.getFlattenedTree(document); runRules('#not-happening', {}, function () { assert.fail('This selector should not exist.'); }, function (error) { diff --git a/test/core/public/run.js b/test/core/public/run.js index e4a872d203..2262b0250a 100644 --- a/test/core/public/run.js +++ b/test/core/public/run.js @@ -91,6 +91,21 @@ describe('axe.run', function () { }); }); + it('should clear axe._tree', function (done) { + var getFlattenedTree = axe.utils.getFlattenedTree; + var thing = 'honey badger'; + axe.utils.getFlattenedTree = function () { + return thing; + }; + axe._runRules = function () { + assert.isTrue(typeof axe._tree === 'undefined'); + axe.utils.getFlattenedTree = getFlattenedTree; + done(); + }; + + axe.run({ someOption: true }, noop); + }); + describe('callback', function () { it('gives errors to the first argument on the callback', function (done) { axe._runRules = function (ctxt, opt, resolve, reject) { diff --git a/test/core/utils/collect-results-from-frames.js b/test/core/utils/collect-results-from-frames.js index a1a4202c2c..208ac8851a 100644 --- a/test/core/utils/collect-results-from-frames.js +++ b/test/core/utils/collect-results-from-frames.js @@ -1,74 +1,78 @@ /*global Context */ describe('axe.utils.collectResultsFromFrames', function () { - 'use strict'; + 'use strict'; - var fixture = document.getElementById('fixture'); - var noop = function () {}; + var fixture = document.getElementById('fixture'); + var noop = function () {}; afterEach(function () { fixture.innerHTML = ''; + axe._tree = undefined; + axe._selectorData = undefined; }); - it('should timeout after 60s', function (done) { - var orig = window.setTimeout; - window.setTimeout = function (fn, to) { - if (to === 60000) { - assert.ok('timeout set'); - fn(); - } else { // ping timeout - return orig(fn, to); - } - return 'cats'; - }; - - var frame = document.createElement('iframe'); - frame.addEventListener('load', function () { - var context = new Context(document); - axe.utils.collectResultsFromFrames(context, {}, 'stuff', 'morestuff', noop, - function (err) { - assert.instanceOf(err, Error); - assert.equal(err.message.split(/: /)[0], 'Axe in frame timed out'); - window.setTimeout = orig; - done(); - }); - }); - - frame.id = 'level0'; - frame.src = '../mock/frames/results-timeout.html'; - fixture.appendChild(frame); - - }); - - it('should override the timeout with `options.frameWaitTime`, if provided', function (done) { - var orig = window.setTimeout; - window.setTimeout = function (fn, to) { - if (to === 90000) { - assert.ok('timeout set'); - fn(); - } else { // ping timeout - return orig(fn, to); - } - return 'cats'; - }; - - var frame = document.createElement('iframe'); - frame.addEventListener('load', function () { - var context = new Context(document); - var params = { frameWaitTime: 90000 }; - axe.utils.collectResultsFromFrames(context, params, 'stuff', 'morestuff', noop, - function (err) { - assert.instanceOf(err, Error); - assert.equal(err.message.split(/: /)[0], 'Axe in frame timed out'); - window.setTimeout = orig; - done(); - }); - }); - - frame.id = 'level0'; - frame.src = '../mock/frames/results-timeout.html'; - fixture.appendChild(frame); - - }); + it('should timeout after 60s', function (done) { + var orig = window.setTimeout; + window.setTimeout = function (fn, to) { + if (to === 60000) { + assert.ok('timeout set'); + fn(); + } else { // ping timeout + return orig(fn, to); + } + return 'cats'; + }; + + var frame = document.createElement('iframe'); + frame.addEventListener('load', function () { + var context = new Context(document); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + axe.utils.collectResultsFromFrames(context, {}, 'stuff', 'morestuff', noop, + function (err) { + assert.instanceOf(err, Error); + assert.equal(err.message.split(/: /)[0], 'Axe in frame timed out'); + window.setTimeout = orig; + done(); + }); + }); + + frame.id = 'level0'; + frame.src = '../mock/frames/results-timeout.html'; + fixture.appendChild(frame); + + }); + + it('should override the timeout with `options.frameWaitTime`, if provided', function (done) { + var orig = window.setTimeout; + window.setTimeout = function (fn, to) { + if (to === 90000) { + assert.ok('timeout set'); + fn(); + } else { // ping timeout + return orig(fn, to); + } + return 'cats'; + }; + + var frame = document.createElement('iframe'); + frame.addEventListener('load', function () { + var context = new Context(document); + var params = { frameWaitTime: 90000 }; + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + axe.utils.collectResultsFromFrames(context, params, 'stuff', 'morestuff', noop, + function (err) { + assert.instanceOf(err, Error); + assert.equal(err.message.split(/: /)[0], 'Axe in frame timed out'); + window.setTimeout = orig; + done(); + }); + }); + + frame.id = 'level0'; + frame.src = '../mock/frames/results-timeout.html'; + fixture.appendChild(frame); + + }); it('should not throw given a recursive iframe', function (done) { axe._load({ @@ -88,12 +92,13 @@ describe('axe.utils.collectResultsFromFrames', function () { var frame = document.createElement('iframe'); frame.addEventListener('load', function () { var context = new Context(document); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); axe.utils.collectResultsFromFrames(context, {}, 'rules', 'morestuff', function () { done(); }, function (e) { - assert.ok(false, e); - done(); - }); + assert.ok(false, e); + done(); + }); }); frame.id = 'level0'; @@ -102,23 +107,24 @@ describe('axe.utils.collectResultsFromFrames', function () { }); - it('returns errors send from the frame', function (done) { - var frame = document.createElement('iframe'); - frame.addEventListener('load', function () { - var context = new Context(document); - axe.utils.collectResultsFromFrames(context, {}, 'command', 'params', noop, - function (err) { + it('returns errors send from the frame', function (done) { + var frame = document.createElement('iframe'); + frame.addEventListener('load', function () { + var context = new Context(document); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + axe.utils.collectResultsFromFrames(context, {}, 'command', 'params', noop, + function (err) { - assert.instanceOf(err, Error); - assert.equal(err.message.split(/\n/)[0], 'error in axe.throw'); - done(); - }); + assert.instanceOf(err, Error); + assert.equal(err.message.split(/\n/)[0], 'error in axe.throw'); + done(); + }); - }); + }); - frame.id = 'level0'; - frame.src = '../mock/frames/throwing.html'; - fixture.appendChild(frame); - }); + frame.id = 'level0'; + frame.src = '../mock/frames/throwing.html'; + fixture.appendChild(frame); + }); }); diff --git a/test/core/utils/dq-element.js b/test/core/utils/dq-element.js index b0fedf2716..c47ad9fcef 100644 --- a/test/core/utils/dq-element.js +++ b/test/core/utils/dq-element.js @@ -6,6 +6,8 @@ describe('DqElement', function () { afterEach(function () { fixture.innerHTML = ''; + axe._tree = undefined; + axe._selectorData = undefined; }); it('should be a function', function () { @@ -31,6 +33,7 @@ describe('DqElement', function () { it('should not be present in stringified version', function () { var div = document.createElement('div'); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var dqEl = new DqElement(div); assert.isUndefined(JSON.parse(JSON.stringify(dqEl)).element); @@ -137,6 +140,7 @@ describe('DqElement', function () { describe('absolutePaths', function () { it('creates a path all the way to root', function () { fixture.innerHTML = '
Hello!
'; + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var result = new DqElement(fixture.firstChild, { absolutePaths: true }); diff --git a/test/core/utils/get-selector.js b/test/core/utils/get-selector.js index 540b77f2c0..15b7d66b99 100644 --- a/test/core/utils/get-selector.js +++ b/test/core/utils/get-selector.js @@ -14,6 +14,32 @@ function makeShadowTreeGetSelector(node) { div.appendChild(createContentGetSelector()); } +function makeNonunique(fixture) { + 'use strict'; + var nonUnique = '
'; + fixture.innerHTML = '
' + + nonUnique + nonUnique + nonUnique + + '
'; + var node = document.createElement('div'); + var parent = fixture.querySelector('div:nth-child(4) > div'); + parent.appendChild(node); + return node; +} + +function makeNonuniqueLongAttributes(fixture) { + 'use strict'; + var nonUnique = '
'; + fixture.innerHTML = '
' + + nonUnique + nonUnique + nonUnique + + '
'; + var node = document.createElement('div'); + node.setAttribute('data-att', 'ddfkjghlkdddfkjghlkdddfkjghlkdddfkjghlkd'); + var parent = fixture.querySelector('div:nth-child(4) > div'); + parent.appendChild(node); + return node; +} + + describe('axe.utils.getSelector', function () { 'use strict'; @@ -22,6 +48,8 @@ describe('axe.utils.getSelector', function () { afterEach(function () { fixture.innerHTML = ''; + axe._tree = undefined; + axe._selectorData = undefined; }); it('should be a function', function () { @@ -31,11 +59,10 @@ describe('axe.utils.getSelector', function () { it('should generate a unique CSS selector', function () { var node = document.createElement('div'); fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var sel = axe.utils.getSelector(node); - assert.equal(sel, '#fixture > div'); - var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); assert.equal(result[0], node); @@ -45,30 +72,31 @@ describe('axe.utils.getSelector', function () { var node = document.createElement('div'); node.className = ' '; fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var sel = axe.utils.getSelector(node); - assert.equal(sel, '#fixture > div'); - var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); assert.equal(result[0], node); }); - it('should handle special characters', function () { + it('should handle special characters in IDs', function () { var node = document.createElement('div'); node.id = 'monkeys#are.animals\\ok'; fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var result = document.querySelectorAll(axe.utils.getSelector(node)); assert.lengthOf(result, 1); assert.equal(result[0], node); }); - it('should handle special characters in className', function () { + it('should handle special characters in classNames', function () { var node = document.createElement('div'); node.className = '. bb-required'; fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var result = document.querySelectorAll(axe.utils.getSelector(node)); assert.lengthOf(result, 1); @@ -84,16 +112,18 @@ describe('axe.utils.getSelector', function () { expected = node; } } + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var result = document.querySelectorAll(axe.utils.getSelector(expected)); assert.lengthOf(result, 1); assert.equal(result[0], expected); }); - it('should stop on unique ID', function () { + it('should use a unique ID', function () { var node = document.createElement('div'); node.id = 'monkeys'; fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var sel = axe.utils.getSelector(node); @@ -113,11 +143,11 @@ describe('axe.utils.getSelector', function () { node = document.createElement('div'); node.id = 'monkeys'; fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var sel = axe.utils.getSelector(node); - assert.equal(sel, '#fixture > div:nth-child(2)'); - + assert.notInclude(sel, '#monkeys'); var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); assert.equal(result[0], node); @@ -131,10 +161,52 @@ describe('axe.utils.getSelector', function () { node = document.createElement('div'); node.className = 'dogs cats'; fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + + assert.equal(sel, '.dogs'); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + + }); + + it('should use classes if more unique than the tag', function () { + var node = document.createElement('p'); + node.className = 'monkeys simian cats'; + fixture.appendChild(node); + + node = document.createElement('p'); + node.className = 'dogs cats'; + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + + assert.equal(sel, '.dogs'); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + + }); + + it('should NOT use classes if they are more common than the tag', function () { + var node = document.createElement('p'); + node.className = 'dogs cats'; + fixture.appendChild(node); + + node = document.createElement('p'); + node.className = 'dogs cats'; + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var sel = axe.utils.getSelector(node); - assert.equal(sel, '#fixture > div.dogs.cats'); + assert.isTrue(sel.indexOf('.dogs') === -1); + assert.isTrue(sel.indexOf('p') === 0); var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); @@ -142,7 +214,206 @@ describe('axe.utils.getSelector', function () { }); - it('should default to tagName and position if classes are not unique', function () { + it('should use the most unique class', function () { + var node = document.createElement('div'); + node.className = 'dogs'; + fixture.appendChild(node); + + node = document.createElement('div'); + node.className = 'dogs cats'; + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + + assert.equal(sel, '.cats'); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + + }); + + it('should use the most unique class and not the unique attribute', function () { + var node = document.createElement('div'); + node.className = 'dogs'; + fixture.appendChild(node); + + node = document.createElement('div'); + node.className = 'dogs cats'; + node.setAttribute('data-axe', 'hello'); + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + + assert.equal(sel, '.cats'); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + + }); + + it('should use only a single unique attribute', function () { + var node = document.createElement('div'); + node.setAttribute('data-thing', 'hello'); + fixture.appendChild(node); + + node = document.createElement('div'); + node.setAttribute('data-axe', 'hello'); + node.setAttribute('data-thing', 'hello'); + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + + assert.equal(sel, 'div[data-axe="hello"]'); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + + }); + + it('should use three uncommon but not unique features', function () { + var node = document.createElement('div'); + node.setAttribute('data-axe', 'hello'); + node.setAttribute('data-thing', 'hello'); + node.className = 'thing'; + fixture.appendChild(node); + + node = document.createElement('div'); + node.setAttribute('data-axe', 'hello'); + node.setAttribute('data-thing', 'hello'); + node.className = 'thing'; + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + var clsIndex = sel.indexOf('.thing'); + var attIndex = Math.min(sel.indexOf('[data-axe="hello"]'), + sel.indexOf('[data-thing="hello"]')); + + assert.isTrue(clsIndex !== -1); + assert.isTrue(sel.indexOf('[data-axe="hello"]') !== -1); + assert.isTrue(sel.indexOf('[data-thing="hello"]') !== -1); + + assert.isTrue(clsIndex < attIndex, 'classes first'); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + + }); + + it('should use only three uncommon but not unique features', function () { + var node = document.createElement('div'); + node.setAttribute('data-axe', 'hello'); + node.setAttribute('data-thing', 'hello'); + node.setAttribute('data-thang', 'hello'); + node.className = 'thing thang'; + fixture.appendChild(node); + + node = document.createElement('div'); + node.setAttribute('data-axe', 'hello'); + node.setAttribute('data-thing', 'hello'); + node.setAttribute('data-thang', 'hello'); + node.className = 'thing thang'; + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + var parts = sel.split('.'); + parts = parts.reduce(function (val, item) { + var its = item.split('['); + return val.concat(its); + }, []).filter(function (item) { + return item !== ''; + }); + assert.equal(parts.length, 3); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + }); + + it('should use only three uncommon but not unique classes', function () { + var node = document.createElement('div'); + node.className = 'thing thang thug thick'; + fixture.appendChild(node); + + node = document.createElement('div'); + node.className = 'thing thang thug thick'; + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + var parts = sel.split('.'); + parts = parts.reduce(function (val, item) { + var its = item.split('['); + return val.concat(its); + }, []).filter(function (item) { + return item !== ''; + }); + assert.equal(parts.length, 3); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + }); + + it('should use only three uncommon but not unique attributes', function () { + var node = document.createElement('div'); + node.setAttribute('data-axe', 'hello'); + node.setAttribute('data-thug', 'hello'); + node.setAttribute('data-thing', 'hello'); + node.setAttribute('data-thang', 'hello'); + fixture.appendChild(node); + + node = document.createElement('div'); + node.setAttribute('data-axe', 'hello'); + node.setAttribute('data-thing', 'hello'); + node.setAttribute('data-thang', 'hello'); + node.setAttribute('data-thug', 'hello'); + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node); + var parts = sel.split('.'); + parts = parts.reduce(function (val, item) { + var its = item.split('['); + return val.concat(its); + }, []).filter(function (item) { + return item !== ''; + }); + assert.equal(parts.length, 4); + + var result = document.querySelectorAll(sel); + assert.lengthOf(result, 1); + assert.equal(result[0], node); + }); + + it('should not use long attributes', function () { + var node = makeNonuniqueLongAttributes(fixture); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node, {}); + assert.isTrue(sel.indexOf('data-att') === -1); + }); + + it('should use :root when not unique html element', function () { + // todo + var node = document.createElement('html'); + node.setAttribute('lang', 'en'); + fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(document.documentElement, {}); + assert.equal(sel, ':root'); + }); + + it('should use position if classes are not unique', function () { var node = document.createElement('div'); node.className = 'monkeys simian'; fixture.appendChild(node); @@ -150,10 +421,11 @@ describe('axe.utils.getSelector', function () { node = document.createElement('div'); node.className = 'monkeys simian'; fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var sel = axe.utils.getSelector(node); - assert.equal(sel, '#fixture > div:nth-child(2)'); + assert.equal(sel, '.monkeys.simian:nth-child(2)'); var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); @@ -162,6 +434,8 @@ describe('axe.utils.getSelector', function () { }); it('should work on the documentElement', function () { + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + var sel = axe.utils.getSelector(document.documentElement); var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); @@ -171,6 +445,8 @@ describe('axe.utils.getSelector', function () { it('should work on the documentElement with classes', function () { var orig = document.documentElement.className; document.documentElement.className = 'stuff and other things'; + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + var sel = axe.utils.getSelector(document.documentElement); var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); @@ -179,6 +455,8 @@ describe('axe.utils.getSelector', function () { }); it('should work on the body', function () { + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + var sel = axe.utils.getSelector(document.body); var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); @@ -188,6 +466,8 @@ describe('axe.utils.getSelector', function () { it('should work on namespaced elements', function () { fixture.innerHTML = 'Hello'; var node = fixture.firstChild; + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + var sel = axe.utils.getSelector(node); var result = document.querySelectorAll(sel); assert.lengthOf(result, 1); @@ -201,6 +481,8 @@ describe('axe.utils.getSelector', function () { 'x' + '' + ''; + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + var node = fixture.querySelector('m\\:ci'); var sel = axe.utils.getSelector(node); var result = document.querySelectorAll(sel); @@ -208,114 +490,64 @@ describe('axe.utils.getSelector', function () { assert.equal(result[0], node); }); - it('shouldn\'t fail if the node\'s parentNode doesnt have children, somehow (Firefox bug)', function () { - var sel = axe.utils.getSelector({ - nodeName: 'a', - classList: [], - getAttribute: function () { }, - hasAttribute: function () { return false; }, - parentNode: { - nodeName: 'b', - getAttribute: function () { }, - hasAttribute: function () { return false; }, - classList: [] - } - }); - assert.equal(sel, 'a'); - }); - - it('should use role attributes', function () { + it('should not use ignored attributes', function () { var node = document.createElement('div'); - node.setAttribute('role', 'menuitem'); + var ignoredAttributes = [ + 'style', + 'selected', 'checked', + 'disabled', 'tabindex', + 'aria-checked', 'aria-selected', + 'aria-invalid', 'aria-activedescendant', + 'aria-busy', 'aria-disabled', 'aria-expanded', + 'aria-grabbed', 'aria-pressed', 'aria-valuenow' + ]; + ignoredAttributes.forEach(function (att) { + node.setAttribute(att, 'true'); + }); fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); - assert.equal( - axe.utils.getSelector(node), - '#fixture > div[role="menuitem"]' + assert.isTrue( + axe.utils.getSelector(node).indexOf('[') === -1 ); }); - it('should use href and src attributes', function () { + it('should use href and src attributes, shortened', function () { var link = document.createElement('a'); + link.setAttribute('href', '//deque.com/thang/'); + fixture.appendChild(link); + link = document.createElement('a'); link.setAttribute('href', '//deque.com/about/'); fixture.appendChild(link); var img = document.createElement('img'); + img.setAttribute('src', '//deque.com/thang.png'); + fixture.appendChild(img); + img = document.createElement('img'); img.setAttribute('src', '//deque.com/logo.png'); fixture.appendChild(img); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + assert.equal( axe.utils.getSelector(link), - '#fixture > a[href$="about/"]' + 'a[href$="about/"]' ); assert.equal( axe.utils.getSelector(img), - '#fixture > img[src$="logo.png"]' + 'img[src$="logo.png"]' ); }); - it('should give use two features on the first element', function () { + it('should not generate universal selectors', function () { var node = document.createElement('div'); node.setAttribute('role', 'menuitem'); fixture.appendChild(node); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); assert.equal( axe.utils.getSelector(node), - '#fixture > div[role="menuitem"]' - ); - - node.className = 'dqpl-btn-primary'; - assert.equal( - axe.utils.getSelector(node), - '#fixture > [role="menuitem"].dqpl-btn-primary' - ); - }); - - it('should give use one features on the subsequent elements', function () { - var span = document.createElement('span'); - var node = document.createElement('div'); - node.setAttribute('role', 'menuitem'); - span.className = 'expand-icon'; - node.appendChild(span); - fixture.appendChild(node); - - assert.equal( - axe.utils.getSelector(span), - '[role="menuitem"] > span.expand-icon' - ); - }); - - it('should prioritize uncommon tagNames', function () { - var node = document.createElement('button'); - node.setAttribute('role', 'menuitem'); - node.className = 'dqpl-btn-primary'; - fixture.appendChild(node); - assert.equal( - axe.utils.getSelector(node), - '#fixture > button[role="menuitem"]' - ); - }); - - it('should add [type] to input elements', function () { - var node = document.createElement('input'); - node.type = 'password'; - node.className = 'dqpl-textfield'; - fixture.appendChild(node); - assert.equal( - axe.utils.getSelector(node), - '#fixture > input[type="password"].dqpl-textfield' - ); - }); - - it('should use the name property', function () { - var node = document.createElement('input'); - node.type = 'text'; - node.name = 'username'; - node.className = 'dqpl-textfield'; - fixture.appendChild(node); - assert.equal( - axe.utils.getSelector(node), - '#fixture > input[type="text"][name="username"]' + 'div[role="menuitem"]' ); }); @@ -327,6 +559,8 @@ describe('axe.utils.getSelector', function () { // to specifically test this fixture.innerHTML = '
'; makeShadowTreeGetSelector(fixture.firstChild); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + shadEl = fixture.firstChild.shadowRoot.querySelector('input#myinput'); assert.deepEqual(axe.utils.getSelector(shadEl), [ '#fixture > div', @@ -342,6 +576,8 @@ describe('axe.utils.getSelector', function () { // to specifically test this fixture.innerHTML = '
'; makeShadowTreeGetSelector(fixture.firstChild); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + shadEl = fixture.firstChild.shadowRoot.querySelector('input#myinput'); assert.deepEqual(axe.utils.getSelector(shadEl, { toRoot: true }), [ 'html > body > #fixture > div', @@ -350,4 +586,24 @@ describe('axe.utils.getSelector', function () { } }); + it('should correctly calculate unique selector when no discernable features', function () { + var node = makeNonunique(fixture); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var sel = axe.utils.getSelector(node, {}); + var mine = document.querySelector(sel); + assert.isTrue(mine === node); + }); + + it('should not traverse further up than required when no discernable features', function () { + var node = makeNonunique(fixture); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + + var top = fixture.querySelector('div:nth-child(4)'); + var sel = axe.utils.getSelector(node, {}); + sel = sel.substring(0, sel.indexOf(' >')); + var test = document.querySelector(sel); + assert.isTrue(test === top); + }); + }); diff --git a/test/core/utils/send-command-to-frame.js b/test/core/utils/send-command-to-frame.js index b2f34e1d42..e320696925 100644 --- a/test/core/utils/send-command-to-frame.js +++ b/test/core/utils/send-command-to-frame.js @@ -5,6 +5,8 @@ describe('axe.utils.sendCommandToFrame', function () { afterEach(function () { fixture.innerHTML = ''; + axe._tree = undefined; + axe._selectorData = undefined; }); var assertNotCalled = function () { @@ -25,6 +27,7 @@ describe('axe.utils.sendCommandToFrame', function () { var frame = document.createElement('iframe'); frame.addEventListener('load', function () { + axe._tree = axe.utils.getFlattenedTree(document.documentElement); axe.utils.sendCommandToFrame(frame, {}, function (result) { assert.equal(result, null); done(); @@ -81,7 +84,7 @@ describe('axe.utils.sendCommandToFrame', function () { }); it('should respond once when no keepalive', function (done) { - var number = 2; + var number = 1; var called = 0; var frame = document.createElement('iframe'); frame.addEventListener('load', function () { @@ -90,16 +93,13 @@ describe('axe.utils.sendCommandToFrame', function () { }, function () { called += 1; if (called === number) { - clearTimeout(timer); - assert.isTrue(false); + assert.isTrue(true); done(); + } else { + throw new Error ('should not have been called'); } }, assertNotCalled); }); - var timer = setTimeout(function () { - assert.isTrue(true); - done(); - }, 150); frame.id = 'level0'; frame.src = '../mock/frames/responder.html';