From 1cae5eae49abe01e6f84c8ee18b5b0c2ff700492 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Wed, 8 Jun 2022 11:22:55 -0600 Subject: [PATCH] fix(utils): greatly improve the speed of querySelectorAll (#3423) * fix(utils): greatly improve the speed of our querySelectorAll code * fixes * finalize * tests * fix ie11 * const... * changes * tie map to cache * fix test * remove test * fixes * revert rule descriptions --- lib/core/core.js | 10 + lib/core/utils/get-flattened-tree.js | 26 +- lib/core/utils/matches.js | 5 + lib/core/utils/query-selector-all-filter.js | 12 + lib/core/utils/selector-cache.js | 206 ++++++++++ test/core/utils/flattened-tree.js | 5 + test/core/utils/matches.js | 18 + test/core/utils/qsa.js | 414 +++++++++++--------- test/core/utils/selector-cache.js | 272 +++++++++++++ 9 files changed, 782 insertions(+), 186 deletions(-) create mode 100644 lib/core/utils/selector-cache.js create mode 100644 test/core/utils/selector-cache.js diff --git a/lib/core/core.js b/lib/core/core.js index 7664e8b676..2aded83b4a 100644 --- a/lib/core/core.js +++ b/lib/core/core.js @@ -46,6 +46,11 @@ import v2Reporter from './reporters/v2'; import * as commons from '../commons'; import * as utils from './utils'; +import { + cacheNodeSelectors, + getNodesMatchingExpression +} from './utils/selector-cache'; +import { convertSelector } from './utils/matches'; axe.constants = constants; axe.log = log; @@ -70,6 +75,11 @@ axe._thisWillBeDeletedDoNotUse.base = { axe._thisWillBeDeletedDoNotUse.public = { reporters }; +axe._thisWillBeDeletedDoNotUse.utils = + axe._thisWillBeDeletedDoNotUse.utils || {}; +axe._thisWillBeDeletedDoNotUse.utils.cacheNodeSelectors = cacheNodeSelectors; +axe._thisWillBeDeletedDoNotUse.utils.getNodesMatchingExpression = getNodesMatchingExpression; +axe._thisWillBeDeletedDoNotUse.utils.convertSelector = convertSelector; axe.imports = imports; diff --git a/lib/core/utils/get-flattened-tree.js b/lib/core/utils/get-flattened-tree.js index 6bddc10ab3..13299c2923 100644 --- a/lib/core/utils/get-flattened-tree.js +++ b/lib/core/utils/get-flattened-tree.js @@ -1,6 +1,7 @@ import isShadowRoot from './is-shadow-root'; import VirtualNode from '../base/virtual-node/virtual-node'; import cache from '../base/cache'; +import { cacheNodeSelectors } from './selector-cache'; /** * This implemnts the flatten-tree algorithm specified: @@ -40,6 +41,20 @@ function getSlotChildren(node) { return retVal; } +/** + * Create a virtual node + * @param {Node} node the current node + * @param {VirtualNode} parent the parent VirtualNode + * @param {String} shadowId, optional ID of the shadow DOM that is the closest shadow ancestor of the node + * @return {VirtualNode} + */ +function createNode(node, parent, shadowId) { + const vNode = new VirtualNode(node, parent, shadowId); + cacheNodeSelectors(vNode, cache.get('selectorMap')); + + return vNode; +} + /** * Recursvely returns an array of the virtual DOM nodes at this level * excluding comment nodes and the shadow DOM nodes and @@ -71,7 +86,7 @@ function flattenTree(node, shadowId, parent) { // generate an ID for this shadow root and overwrite the current // closure shadowId with this value so that it cascades down the tree - retVal = new VirtualNode(node, parent, shadowId); + retVal = createNode(node, parent, shadowId); shadowId = 'a' + Math.random() @@ -106,7 +121,7 @@ function flattenTree(node, shadowId, parent) { if (false && styl.display !== 'contents') { // intentionally commented out // has a box - retVal = new VirtualNode(node, parent, shadowId); + retVal = createNode(node, parent, shadowId); retVal.children = realArray.reduce((res, child) => { return reduceShadowDOM(res, child, retVal); }, []); @@ -119,7 +134,7 @@ function flattenTree(node, shadowId, parent) { } } else { if (node.nodeType === 1) { - retVal = new VirtualNode(node, parent, shadowId); + retVal = createNode(node, parent, shadowId); realArray = Array.from(node.childNodes); retVal.children = realArray.reduce((res, child) => { return reduceShadowDOM(res, child, retVal); @@ -128,7 +143,7 @@ function flattenTree(node, shadowId, parent) { return [retVal]; } else if (node.nodeType === 3) { // text - return [new VirtualNode(node, parent)]; + return [createNode(node, parent)]; } return undefined; } @@ -145,12 +160,15 @@ function flattenTree(node, shadowId, parent) { */ function getFlattenedTree(node = document.documentElement, shadowId) { hasShadowRoot = false; + const selectorMap = {}; cache.set('nodeMap', new WeakMap()); + cache.set('selectorMap', selectorMap); // specifically pass `null` to the parent to designate the top // node of the tree. if parent === undefined then we know // we are in a disconnected tree const tree = flattenTree(node, shadowId, null); + tree[0]._selectorMap = selectorMap; // allow rules and checks to know if there is a shadow root attached // to the current tree diff --git a/lib/core/utils/matches.js b/lib/core/utils/matches.js index ee1461498d..412dfcf837 100644 --- a/lib/core/utils/matches.js +++ b/lib/core/utils/matches.js @@ -128,6 +128,7 @@ function convertAttributes(atts) { return { key: attributeKey, value: attributeValue, + type: typeof att.value === 'undefined' ? 'attrExist' : 'attrValue', test: test }; }); @@ -224,6 +225,10 @@ export function convertSelector(selector) { * @returns {Boolean} */ function optimizedMatchesExpression(vNode, expressions, index, matchAnyParent) { + if (!vNode) { + return false; + } + const isArray = Array.isArray(expressions); const expression = isArray ? expressions[index] : expressions; let matches = matchExpression(vNode, expression); diff --git a/lib/core/utils/query-selector-all-filter.js b/lib/core/utils/query-selector-all-filter.js index b4b59d71ff..770e3651c1 100644 --- a/lib/core/utils/query-selector-all-filter.js +++ b/lib/core/utils/query-selector-all-filter.js @@ -1,4 +1,5 @@ import { matchesExpression, convertSelector } from './matches'; +import { getNodesMatchingExpression } from './selector-cache'; function createLocalVariables( vNodes, @@ -124,6 +125,17 @@ function matchExpressions(domTree, expressions, filter) { function querySelectorAllFilter(domTree, selector, filter) { domTree = Array.isArray(domTree) ? domTree : [domTree]; const expressions = convertSelector(selector); + + // see if the passed in node is the root node of the tree and can + // find nodes using the cache rather than looping through the + // the entire tree + const nodes = getNodesMatchingExpression(domTree, expressions, filter); + if (nodes) { + return nodes; + } + + // if the selector cache is not set up or if not passed the + // top level node we default back to parsing the whole tree return matchExpressions(domTree, expressions, filter); } diff --git a/lib/core/utils/selector-cache.js b/lib/core/utils/selector-cache.js new file mode 100644 index 0000000000..5f81700678 --- /dev/null +++ b/lib/core/utils/selector-cache.js @@ -0,0 +1,206 @@ +import { matchesExpression } from './matches'; +import tokenList from './token-list'; + +// since attribute names can't contain whitespace, this will be +// a reserved list for ids so we can perform virtual id lookups +const idsKey = ' [idsMap]'; + +/** + * Get nodes from the selector cache that match the selector. + * @param {VirtualTree[]} domTree flattened tree collection to search + * @param {Object} expressions + * @param {Function} filter function (optional) + * @return {Mixed} Array of nodes that match the selector or undefined if the selector map is not setup + */ +export function getNodesMatchingExpression(domTree, expressions, filter) { + // check to see if the domTree is the root and has the selector + // map. if not we just return and let our QSA code do the finding + const selectorMap = domTree[0]._selectorMap; + if (!selectorMap) { + return; + } + + const shadowId = domTree[0].shadowId; + + // if the selector uses a global selector with a combinator + // (e.g. A *, A > *) it's actually faster to use our QSA code than + // getting all nodes and using matchesExpression + for (let i = 0; i < expressions.length; i++) { + if ( + expressions[i].length > 1 && + expressions[i].some(expression => isGlobalSelector(expression)) + ) { + return; + } + } + + // it turned out to be more performant to use a Set to generate a + // unique list of nodes rather than an array and array.includes + // (~3 seconds total on a benchmark site) + const nodeSet = new Set(); + + expressions.forEach(expression => { + const matchingNodes = findMatchingNodes(expression, selectorMap, shadowId); + matchingNodes?.nodes?.forEach(node => { + // for complex selectors we need to verify that the node + // actually matches the entire selector since we only have + // nodes that partially match the last part of the selector + if ( + matchingNodes.isComplexSelector && + !matchesExpression(node, expression) + ) { + return; + } + + nodeSet.add(node); + }); + }); + + // Sets in ie11 do not work with Array.from without a polyfill + //(missing `.entries`), but do have forEach + let matchedNodes = []; + nodeSet.forEach(node => matchedNodes.push(node)); + + if (filter) { + matchedNodes = matchedNodes.filter(filter); + } + + return matchedNodes.sort((a, b) => a.nodeIndex - b.nodeIndex); +} + +/** + * Add nodes to the passed in Set that match just a part of the selector in order to speed up traversing the entire tree. + * @param {Object} expression Selector Expression + * @param {Object} selectorMap Selector map cache + * @param {String} shadowId ShadowID of the root node + */ +function findMatchingNodes(expression, selectorMap, shadowId) { + // use the last part of the expression to find nodes as it's more + // specific. e.g. for `body h1` use `h1` and not `body` + const exp = expression[expression.length - 1]; + let nodes = null; + + // a complex selector is one that will require using + // matchesExpression to determine if it matches. these include + // pseudo selectors (:not), combinators (A > B), and any + // attribute value ([class=foo]). + let isComplexSelector = + expression.length > 1 || !!exp.pseudos || !!exp.classes; + + if (isGlobalSelector(exp)) { + nodes = selectorMap['*']; + } else { + if (exp.id) { + // a selector must match all parts, otherwise we can just exit + // early + if (!selectorMap[idsKey] || !selectorMap[idsKey][exp.id]?.length) { + return; + } + + // when using id selector (#one) we only find nodes that + // match the shadowId of the root + nodes = selectorMap[idsKey][exp.id].filter( + node => node.shadowId === shadowId + ); + } + + if (exp.tag && exp.tag !== '*') { + if (!selectorMap[exp.tag]?.length) { + return; + } + + const cachedNodes = selectorMap[exp.tag]; + nodes = nodes ? getSharedValues(cachedNodes, nodes) : cachedNodes; + } + + if (exp.classes) { + if (!selectorMap['[class]']?.length) { + return; + } + + const cachedNodes = selectorMap['[class]']; + nodes = nodes ? getSharedValues(cachedNodes, nodes) : cachedNodes; + } + + if (exp.attributes) { + for (let i = 0; i < exp.attributes.length; i++) { + const attr = exp.attributes[i]; + + // an attribute selector that looks for a specific value is + // a complex selector + if (attr.type === 'attrValue') { + isComplexSelector = true; + } + + if (!selectorMap[`[${attr.key}]`]?.length) { + return; + } + + const cachedNodes = selectorMap[`[${attr.key}]`]; + nodes = nodes ? getSharedValues(cachedNodes, nodes) : cachedNodes; + } + } + } + + return { nodes, isComplexSelector }; +} + +/** + * Non-tag selectors use `*` for the tag name so a global selector won't have any other properties of the expression. Pseudo selectors that use `*` (e.g. `*:not([class])`) will still be considered a global selector since we don't cache anything for pseudo selectors and will rely on filtering with matchesExpression. + * @param {Object} expression Selector Expression + * @returns {Boolean} + */ +function isGlobalSelector(expression) { + return ( + expression.tag === '*' && + !expression.attributes && + !expression.id && + !expression.classes + ); +} + +/** + * Find all nodes in A that are also in B. + * @param {Mixed[]} a + * @param {Mixed[]} b + * @returns {Mixed[]} + */ +function getSharedValues(a, b) { + return a.filter(node => b.includes(node)); +} + +/** + * Save a selector and vNode to the selectorMap. + * @param {String} key + * @param {VirtualNode} vNode + * @param {Object} map + */ +function cacheSelector(key, vNode, map) { + map[key] = map[key] || []; + map[key].push(vNode); +} + +/** + * Cache selector information about a VirtalNode. + * @param {VirtualNode} vNode + */ +export function cacheNodeSelectors(vNode, selectorMap) { + if (vNode.props.nodeType !== 1) { + return; + } + + cacheSelector(vNode.props.nodeName, vNode, selectorMap); + cacheSelector('*', vNode, selectorMap); + + vNode.attrNames.forEach(attrName => { + // element ids are the only values we'll match + if (attrName === 'id') { + selectorMap[idsKey] = selectorMap[idsKey] || {}; + tokenList(vNode.attr(attrName)).forEach(value => { + cacheSelector(value, vNode, selectorMap[idsKey]); + }); + } + + cacheSelector(`[${attrName}]`, vNode, selectorMap); + }); +} diff --git a/test/core/utils/flattened-tree.js b/test/core/utils/flattened-tree.js index 9c458339de..6752837197 100644 --- a/test/core/utils/flattened-tree.js +++ b/test/core/utils/flattened-tree.js @@ -107,6 +107,11 @@ describe('axe.utils.getFlattenedTree', function() { assert.equal(vNode.children[1].children[0].props.nodeName, 's'); }); + it('should add selectorMap to root element', function() { + var tree = axe.utils.getFlattenedTree(); + assert.exists(tree[0]._selectorMap); + }); + if (shadowSupport.v0) { describe('shadow DOM v0', function() { beforeEach(function() { diff --git a/test/core/utils/matches.js b/test/core/utils/matches.js index 16666de7c4..ad5aac90dc 100644 --- a/test/core/utils/matches.js +++ b/test/core/utils/matches.js @@ -2,6 +2,7 @@ describe('utils.matches', function() { var matches = axe.utils.matches; var fixture = document.querySelector('#fixture'); var queryFixture = axe.testUtils.queryFixture; + var convertSelector = axe._thisWillBeDeletedDoNotUse.utils.convertSelector; afterEach(function() { fixture.innerHTML = ''; @@ -322,4 +323,21 @@ describe('utils.matches', function() { }); }); }); + + describe('convertSelector', function() { + it('should set type to attrExist for attribute selector', function() { + var expression = convertSelector('[disabled]'); + assert.equal(expression[0][0].attributes[0].type, 'attrExist'); + }); + + it('should set type to attrValue for attribute value selector', function() { + var expression = convertSelector('[aria-pressed="true"]'); + assert.equal(expression[0][0].attributes[0].type, 'attrValue'); + }); + + it('should set type to attrValue for empty attribute value selector', function() { + var expression = convertSelector('[aria-pressed=""]'); + assert.equal(expression[0][0].attributes[0].type, 'attrValue'); + }); + }); }); diff --git a/test/core/utils/qsa.js b/test/core/utils/qsa.js index db5dd63f34..4ff2cb0d1e 100644 --- a/test/core/utils/qsa.js +++ b/test/core/utils/qsa.js @@ -57,190 +57,240 @@ describe('axe.utils.querySelectorAllFilter', function() { 'use strict'; var dom; afterEach(function() {}); - beforeEach(function() { - dom = getTestDom(); - }); - it('should find nodes using just the tag', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'li'); - assert.equal(result.length, 4); - }); - it('should find nodes using parent selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'ul > li'); - assert.equal(result.length, 4); - }); - it('should NOT find nodes using parent selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'div > li'); - assert.equal(result.length, 0); - }); - it('should find nodes using nested parent selectors', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - 'span > span > span > span' - ); - assert.equal(result.length, 2); - }); - it('should find nodes using hierarchical selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'div li'); - assert.equal(result.length, 4); - }); - it('should find nodes using class selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, '.breaking'); - assert.equal(result.length, 2); - }); - it('should find nodes using hierarchical class selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, '.first .breaking'); - assert.equal(result.length, 2); - }); - it('should NOT find nodes using hierarchical class selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, '.second .breaking'); - assert.equal(result.length, 0); - }); - it('should find nodes using multiple class selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, '.second.third'); - assert.equal(result.length, 1); - }); - it('should find nodes using id', function() { - var result = axe.utils.querySelectorAllFilter(dom, '#one'); - assert.equal(result.length, 1); - }); - it('should find nodes using id, but not in shadow DOM', function() { - var result = axe.utils.querySelectorAllFilter(dom[0].children[0], '#one'); - assert.equal(result.length, 1); - }); - it('should find nodes using id, within a shadow DOM', function() { - var result = axe.utils.querySelectorAllFilter( - dom[0].children[0].children[2], - '#one' - ); - assert.equal(result.length, 1); - }); - it('should find nodes using attribute', function() { - var result = axe.utils.querySelectorAllFilter(dom, '[role]'); - assert.equal(result.length, 2); - }); - it('should find nodes using attribute with value', function() { - var result = axe.utils.querySelectorAllFilter(dom, '[role=tab]'); - assert.equal(result.length, 1); - }); - it('should find nodes using attribute with value', function() { - var result = axe.utils.querySelectorAllFilter(dom, '[role="button"]'); - assert.equal(result.length, 1); - }); - it('should find nodes using parent attribute with value', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - '[data-a11yhero="faulkner"] > ul' - ); - assert.equal(result.length, 1); - }); - it('should find nodes using hierarchical attribute with value', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - '[data-a11yhero="faulkner"] li' - ); - assert.equal(result.length, 2); - }); - it('should find nodes using :not selector with class', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'div:not(.first)'); - assert.equal(result.length, 2); - }); - it('should find nodes using :not selector with matching id', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'div:not(#one)'); - assert.equal(result.length, 2); - }); - it('should find nodes using :not selector with matching attribute selector', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - 'div:not([data-a11yhero])' - ); - assert.equal(result.length, 2); - }); - it('should find nodes using :not selector with matching attribute selector with value', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - 'div:not([data-a11yhero=faulkner])' - ); - assert.equal(result.length, 2); - }); - it('should find nodes using :not selector with bogus attribute selector with value', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - 'div:not([data-a11yhero=wilco])' - ); - assert.equal(result.length, 3); - }); - it('should find nodes using :not selector with bogus id', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'div:not(#thangy)'); - assert.equal(result.length, 3); - }); - it('should find nodes using :not selector with attribute', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'div:not([id])'); - assert.equal(result.length, 2); - }); - it('should find nodes hierarchically using :not selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'div:not(.first) li'); - assert.equal(result.length, 2); - }); - it('should find same nodes hierarchically using more :not selector', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - 'div:not(.first) li:not(.breaking)' - ); - assert.equal(result.length, 2); - }); - it('should NOT find nodes hierarchically using :not selector', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - 'div:not(.second) li:not(.breaking)' - ); - assert.equal(result.length, 0); - }); - it('should find nodes using ^= attribute selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, '[class^="sec"]'); - assert.equal(result.length, 1); - }); - it('should find nodes using $= attribute selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, '[id$="ne"]'); - assert.equal(result.length, 3); - }); - it('should find nodes using *= attribute selector', function() { - var result = axe.utils.querySelectorAllFilter(dom, '[role*="t"]'); - assert.equal(result.length, 2); - }); - it('should put it all together', function() { - var result = axe.utils.querySelectorAllFilter( - dom, - '.first[data-a11yhero="faulkner"] > ul li.breaking' - ); - assert.equal(result.length, 2); - }); - it('should find an element only once', function() { - var divs = axe.utils.querySelectorAllFilter(dom, 'div'); - var ones = axe.utils.querySelectorAllFilter(dom, '#one'); - var divOnes = axe.utils.querySelectorAllFilter(dom, 'div, #one'); - - assert.isBelow( - divOnes.length, - divs.length + ones.length, - 'Elements matching both parts of a selector should not be included twice' - ); - }); - it('should return nodes sorted by document position', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'ul, #one'); - assert.equal(result[0].actualNode.nodeName, 'UL'); - assert.equal(result[1].actualNode.nodeName, 'DIV'); - assert.equal(result[2].actualNode.nodeName, 'UL'); - }); - it('should filter the returned nodes when passed a filter function', function() { - var result = axe.utils.querySelectorAllFilter(dom, 'ul, #one', function( - node - ) { - return node.actualNode.nodeName !== 'UL'; + + var tests = ['without cache', 'with cache']; + for (var i = 0; i < tests.length; i++) { + var describeName = tests[i]; + describe(describeName, function() { + afterEach(function() {}); + + if (describeName === 'without cache') { + beforeEach(function() { + dom = getTestDom(); + + // prove we're using the DOM by deleting the cache + delete dom[0]._selectorCache; + }); + + it('should not have a primed cache', function() { + assert.isUndefined(dom[0]._selectorCache); + }); + } else { + beforeEach(function() { + dom = getTestDom(); + + // prove we're using the cache by deleting all the children + dom[0].children = []; + }); + + it('should not use the cache if not using the top-level node', function() { + var nodes = axe.utils.querySelectorAllFilter(dom, 'ul'); + + // this would return 4 nodes if we were still using the + // top-level cache + var result = axe.utils.querySelectorAllFilter(nodes[0], 'li'); + assert.equal(result.length, 2); + }); + } + + it('should find nodes using just the tag', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'li'); + assert.equal(result.length, 4); + }); + it('should find nodes using parent selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'ul > li'); + assert.equal(result.length, 4); + }); + it('should NOT find nodes using parent selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'div > li'); + assert.equal(result.length, 0); + }); + it('should find nodes using nested parent selectors', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + 'span > span > span > span' + ); + assert.equal(result.length, 2); + }); + it('should find nodes using hierarchical selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'div li'); + assert.equal(result.length, 4); + }); + it('should find nodes using class selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, '.breaking'); + assert.equal(result.length, 2); + }); + it('should find nodes using hierarchical class selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, '.first .breaking'); + assert.equal(result.length, 2); + }); + it('should NOT find nodes using hierarchical class selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, '.second .breaking'); + assert.equal(result.length, 0); + }); + it('should find nodes using multiple class selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, '.second.third'); + assert.equal(result.length, 1); + }); + it('should find nodes using id', function() { + var result = axe.utils.querySelectorAllFilter(dom, '#one'); + assert.equal(result.length, 1); + }); + + // can only select shadow dom nodes when we're not using the + // top-level node. but since the top-level node is the one + // with the cache, this only works when we are testing the full + // tree (i.e. without cache) + if (describeName === 'without cache') { + it('should find nodes using id, but not in shadow DOM', function() { + var result = axe.utils.querySelectorAllFilter( + dom[0].children[0], + '#one' + ); + assert.equal(result.length, 1); + }); + it('should find nodes using id, within a shadow DOM', function() { + var result = axe.utils.querySelectorAllFilter( + dom[0].children[0].children[2], + '#one' + ); + assert.equal(result.length, 1); + }); + } + + it('should find nodes using attribute', function() { + var result = axe.utils.querySelectorAllFilter(dom, '[role]'); + assert.equal(result.length, 2); + }); + it('should find nodes using attribute with value', function() { + var result = axe.utils.querySelectorAllFilter(dom, '[role=tab]'); + assert.equal(result.length, 1); + }); + it('should find nodes using attribute with value', function() { + var result = axe.utils.querySelectorAllFilter(dom, '[role="button"]'); + assert.equal(result.length, 1); + }); + it('should find nodes using parent attribute with value', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + '[data-a11yhero="faulkner"] > ul' + ); + assert.equal(result.length, 1); + }); + it('should find nodes using hierarchical attribute with value', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + '[data-a11yhero="faulkner"] li' + ); + assert.equal(result.length, 2); + }); + it('should find nodes using :not selector with class', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'div:not(.first)'); + assert.equal(result.length, 2); + }); + it('should find nodes using :not selector with matching id', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'div:not(#one)'); + assert.equal(result.length, 2); + }); + it('should find nodes using :not selector with matching attribute selector', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + 'div:not([data-a11yhero])' + ); + assert.equal(result.length, 2); + }); + it('should find nodes using :not selector with matching attribute selector with value', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + 'div:not([data-a11yhero=faulkner])' + ); + assert.equal(result.length, 2); + }); + it('should find nodes using :not selector with bogus attribute selector with value', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + 'div:not([data-a11yhero=wilco])' + ); + assert.equal(result.length, 3); + }); + it('should find nodes using :not selector with bogus id', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'div:not(#thangy)'); + assert.equal(result.length, 3); + }); + it('should find nodes using :not selector with attribute', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'div:not([id])'); + assert.equal(result.length, 2); + }); + it('should find nodes hierarchically using :not selector', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + 'div:not(.first) li' + ); + assert.equal(result.length, 2); + }); + it('should find same nodes hierarchically using more :not selector', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + 'div:not(.first) li:not(.breaking)' + ); + assert.equal(result.length, 2); + }); + it('should NOT find nodes hierarchically using :not selector', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + 'div:not(.second) li:not(.breaking)' + ); + assert.equal(result.length, 0); + }); + it('should find nodes using ^= attribute selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, '[class^="sec"]'); + assert.equal(result.length, 1); + }); + it('should find nodes using $= attribute selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, '[id$="ne"]'); + assert.equal(result.length, 3); + }); + it('should find nodes using *= attribute selector', function() { + var result = axe.utils.querySelectorAllFilter(dom, '[role*="t"]'); + assert.equal(result.length, 2); + }); + it('should put it all together', function() { + var result = axe.utils.querySelectorAllFilter( + dom, + '.first[data-a11yhero="faulkner"] > ul li.breaking' + ); + assert.equal(result.length, 2); + }); + it('should find an element only once', function() { + var divs = axe.utils.querySelectorAllFilter(dom, 'div'); + var ones = axe.utils.querySelectorAllFilter(dom, '#one'); + var divOnes = axe.utils.querySelectorAllFilter(dom, 'div, #one'); + + assert.isBelow( + divOnes.length, + divs.length + ones.length, + 'Elements matching both parts of a selector should not be included twice' + ); + }); + it('should return nodes sorted by document position', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'ul, #one'); + assert.equal(result[0].actualNode.nodeName, 'UL'); + assert.equal(result[1].actualNode.nodeName, 'DIV'); + assert.equal(result[2].actualNode.nodeName, 'UL'); + }); + it('should filter the returned nodes when passed a filter function', function() { + var result = axe.utils.querySelectorAllFilter(dom, 'ul, #one', function( + node + ) { + return node.actualNode.nodeName !== 'UL'; + }); + assert.equal(result[0].actualNode.nodeName, 'DIV'); + assert.equal(result.length, 1); + }); }); - assert.equal(result[0].actualNode.nodeName, 'DIV'); - assert.equal(result.length, 1); - }); + } }); + describe('axe.utils.querySelectorAll', function() { 'use strict'; var dom; diff --git a/test/core/utils/selector-cache.js b/test/core/utils/selector-cache.js new file mode 100644 index 0000000000..c0b7bfaf1b --- /dev/null +++ b/test/core/utils/selector-cache.js @@ -0,0 +1,272 @@ +describe('utils.selector-cache', function() { + var fixture = document.querySelector('#fixture'); + var cacheNodeSelectors = + axe._thisWillBeDeletedDoNotUse.utils.cacheNodeSelectors; + var getNodesMatchingExpression = + axe._thisWillBeDeletedDoNotUse.utils.getNodesMatchingExpression; + var convertSelector = axe.utils.convertSelector; + var shadowSupported = axe.testUtils.shadowSupport.v1; + + var vNode; + beforeEach(function() { + fixture.innerHTML = '
'; + vNode = new axe.VirtualNode(fixture.firstChild); + }); + + describe('cacheNodeSelectors', function() { + it('should add the node to the global selector', function() { + var map = {}; + cacheNodeSelectors(vNode, map); + assert.deepEqual(map['*'], [vNode]); + }); + + it('should add the node to the nodeName', function() { + var map = {}; + cacheNodeSelectors(vNode, map); + assert.deepEqual(map.div, [vNode]); + }); + + it('should add the node to all attribute selectors', function() { + var map = {}; + cacheNodeSelectors(vNode, map); + assert.deepEqual(map['[id]'], [vNode]); + assert.deepEqual(map['[class]'], [vNode]); + assert.deepEqual(map['[aria-label]'], [vNode]); + }); + + it('should add the node to the id map', function() { + var map = {}; + cacheNodeSelectors(vNode, map); + assert.deepEqual(map[' [idsMap]'].target, [vNode]); + }); + + it('should not add the node to selectors it does not match', function() { + var map = {}; + cacheNodeSelectors(vNode, map); + assert.isUndefined(map['[for]']); + assert.isUndefined(map.h1); + }); + + it('should ignore non-element nodes', function() { + var map = {}; + fixture.innerHTML = 'Hello'; + vNode = new axe.VirtualNode(fixture.firstChild); + cacheNodeSelectors(vNode, map); + + assert.lengthOf(Object.keys(map), 0); + }); + }); + + describe('getNodesMatchingExpression', function() { + var tree; + var spanVNode; + var headingVNode; + + function createTree() { + for (var i = 0; i < fixture.children.length; i++) { + var child = fixture.children[i]; + var isShadow = child.hasAttribute('data-shadow'); + var html = child.innerHTML; + if (isShadow) { + var shadowRoot = child.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = html; + child.innerHTML = ''; + } + } + + return axe.utils.getFlattenedTree(fixture); + } + + beforeEach(function() { + fixture.firstChild.innerHTML = + '

'; + tree = axe.utils.getFlattenedTree(fixture.firstChild); + + vNode = tree[0]; + headingVNode = vNode.children[0]; + spanVNode = headingVNode.children[0]; + }); + + it('should return undefined if the cache is not primed', function() { + tree[0]._selectorMap = null; + var expression = convertSelector('div'); + assert.isUndefined(getNodesMatchingExpression(tree, expression)); + }); + + it('should return a list of matching nodes by global selector', function() { + var expression = convertSelector('*'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [ + vNode, + headingVNode, + spanVNode + ]); + }); + + it('should return a list of matching nodes by nodeName', function() { + var expression = convertSelector('div'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [vNode]); + }); + + it('should return a list of matching nodes by id', function() { + var expression = convertSelector('#target'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [vNode]); + }); + + (shadowSupported ? it : xit)( + 'should only return nodes matching shadowId when matching by id', + function() { + fixture.innerHTML = + '
'; + var tree = createTree(); + var expression = convertSelector('#target'); + var expected = [tree[0].children[0]]; + assert.deepEqual( + getNodesMatchingExpression(tree, expression), + expected + ); + } + ); + + it('should return a list of matching nodes by class', function() { + var expression = convertSelector('.foo'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [vNode]); + }); + + it('should return a list of matching nodes by attribute', function() { + var expression = convertSelector('[aria-label]'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [vNode]); + }); + + it('should return an empty array if selector does not match', function() { + var expression = convertSelector('main'); + assert.lengthOf(getNodesMatchingExpression(tree, expression), 0); + }); + + it('should return an empty array for complex selector that does not match', function() { + var expression = convertSelector('span.missingClass[id]'); + assert.lengthOf(getNodesMatchingExpression(tree, expression), 0); + }); + + it('should return an empty array for a non-complex selector that does not match', function() { + var expression = convertSelector('div#not-target[id]'); + assert.lengthOf(getNodesMatchingExpression(tree, expression), 0); + }); + + it('should return nodes for each expression', function() { + fixture.innerHTML = + '
'; + var tree = createTree(); + var expression = convertSelector('[role], [aria-label]'); + var expected = [tree[0].children[0], tree[0].children[1]]; + assert.deepEqual(getNodesMatchingExpression(tree, expression), expected); + }); + + it('should return nodes for child combinator selector', function() { + var expression = convertSelector('div span'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [ + spanVNode + ]); + }); + + it('should return nodes for direct child combinator selector', function() { + var expression = convertSelector('div > h1'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [ + headingVNode + ]); + }); + + it('should not return nodes for direct child combinator selector that does not match', function() { + var expression = convertSelector('div > span'); + assert.lengthOf(getNodesMatchingExpression(tree, expression), 0); + }); + + it('should return nodes for attribute value selector', function() { + var expression = convertSelector('[id="target"]'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [vNode]); + }); + + it('should return undefined for combinator selector with global selector', function() { + var expression = convertSelector('body *'); + assert.isUndefined(getNodesMatchingExpression(tree, expression)); + }); + + it('should return nodes for multipart selectors', function() { + var expression = convertSelector('div.foo[id]'); + assert.deepEqual(getNodesMatchingExpression(tree, expression), [vNode]); + }); + + it('should remove duplicates', function() { + fixture.innerHTML = '
'; + var tree = createTree(); + var expression = convertSelector('div[role], [aria-label]'); + var expected = [tree[0].children[0]]; + assert.deepEqual(getNodesMatchingExpression(tree, expression), expected); + }); + + it('should sort nodes by added order', function() { + fixture.innerHTML = + '
' + + '' + + '
' + + '' + + '
' + + '' + + '
' + + '' + + '
' + + ''; + tree = createTree(); + + var expression = convertSelector('div, span'); + var nodes = getNodesMatchingExpression(tree, expression); + var ids = []; + for (var i = 0; i < nodes.length; i++) { + ids.push(nodes[i].attr('id')); + } + + assert.deepEqual(ids, [ + 'fixture', + 'id0', + 'id1', + 'id2', + 'id3', + 'id4', + 'id5', + 'id6', + 'id7', + 'id8', + 'id9' + ]); + }); + + it('should filter nodes', function() { + fixture.innerHTML = + '
'; + var tree = createTree(); + + function filter(node) { + return node.hasAttr('role'); + } + + var nonFilteredNodes = getNodesMatchingExpression( + tree, + convertSelector('div, [aria-label]') + ); + var nonFilteredExpected = [ + tree[0], + tree[0].children[0], + tree[0].children[1] + ]; + + var filteredNodes = getNodesMatchingExpression( + tree, + convertSelector('div, [aria-label]'), + filter + ); + var filteredExpected = [tree[0].children[0]]; + + assert.deepEqual(nonFilteredNodes, nonFilteredExpected); + assert.deepEqual(filteredNodes, filteredExpected); + }); + }); +});