From be9554be131664d09d0fe0970b3128f865bda4ae Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Thu, 24 Nov 2022 20:53:50 +0100 Subject: [PATCH 01/14] feat(context): allow scoping shadow DOM nodes --- lib/core/base/context.js | 1 + lib/core/base/context/normalize-context.js | 69 +++-- lib/core/base/context/parse-selector-array.js | 14 +- lib/core/public/load.js | 2 + lib/core/public/run-virtual-rule.js | 1 + lib/core/public/run/normalize-run-params.js | 3 + lib/core/utils/contains.js | 44 ++-- lib/core/utils/is-node-in-context.js | 1 + lib/core/utils/select.js | 85 +++---- test/core/base/context.js | 158 +++++++++--- test/core/utils/contains.js | 236 +++++++++++------- test/core/utils/select.js | 32 +-- .../full/context/frames/shadow-frame.html | 21 ++ test/integration/full/context/shadow-dom.html | 41 +++ test/integration/full/context/shadow-dom.js | 45 ++++ test/testutils.js | 16 ++ 16 files changed, 503 insertions(+), 266 deletions(-) create mode 100644 test/integration/full/context/frames/shadow-frame.html create mode 100644 test/integration/full/context/shadow-dom.html create mode 100644 test/integration/full/context/shadow-dom.js diff --git a/lib/core/base/context.js b/lib/core/base/context.js index 6375bc31f4..4984d79911 100644 --- a/lib/core/base/context.js +++ b/lib/core/base/context.js @@ -70,6 +70,7 @@ export default function Context(spec, flatTree) { if (!Array.isArray(this.include)) { this.include = Array.from(this.include); } + // TODO: Should I be sorting frames and exclude? this.include.sort(nodeSorter); // ensure that the order of the include nodes is document order } diff --git a/lib/core/base/context/normalize-context.js b/lib/core/base/context/normalize-context.js index b4eaa779a9..550896fd33 100644 --- a/lib/core/base/context/normalize-context.js +++ b/lib/core/base/context/normalize-context.js @@ -5,48 +5,37 @@ * @return {Object} Normalized context spec to include both `include` and `exclude` arrays */ export function normalizeContext(context) { - // typeof NodeList.length in PhantomJS === function - if ( - (context && typeof context === 'object') || - context instanceof window.NodeList - ) { - if (context instanceof window.Node) { - return { - include: [context], - exclude: [] - }; - } - - if ( - context.hasOwnProperty('include') || - context.hasOwnProperty('exclude') - ) { - return { - include: - context.include && +context.include.length - ? context.include - : [document], - exclude: context.exclude || [] - }; - } - - if (context.length === +context.length) { - return { - include: context, - exclude: [] - }; - } + if (!context) { + return defaultContext(); } - if (typeof context === 'string') { - return { - include: [[context]], - exclude: [] - }; + return defaultContext({ include: [[context]] }); + } + if (context instanceof window.Node) { + return defaultContext({ include: [context] }); + } + if (context instanceof window.NodeList) { + return defaultContext({ include: context }); + } + if (typeof context === 'object' && context.length === +context.length) { + // TODO: validate the content + return defaultContext({ include: context }); + } + + if ( + typeof context === 'object' && + (context.hasOwnProperty('include') || context.hasOwnProperty('exclude')) + ) { + const exclude = + context.exclude && +context.exclude.length ? context.exclude : []; + const include = + context.include && +context.include.length ? context.include : [document]; + return { include, exclude }; } - return { - include: [document], - exclude: [] - }; + return defaultContext(); } + +const defaultContext = ({ include = [document], exclude = [] } = {}) => { + return { include, exclude }; +}; diff --git a/lib/core/base/context/parse-selector-array.js b/lib/core/base/context/parse-selector-array.js index 1d4e183eff..bc228542f6 100644 --- a/lib/core/base/context/parse-selector-array.js +++ b/lib/core/base/context/parse-selector-array.js @@ -1,5 +1,5 @@ import { createFrameContext } from './create-frame-context'; -import { getNodeFromTree } from '../../utils'; +import { getNodeFromTree, shadowSelectAll } from '../../utils'; /** * Finds frames in context, converts selectors to Element references and pushes unique frames @@ -9,12 +9,14 @@ import { getNodeFromTree } from '../../utils'; * @return {Array} Parsed array of matching elements */ export function parseSelectorArray(context, type) { + // TODO: instead of modifying context, this should return things context can used to modify itself const result = []; for (let i = 0, l = context[type].length; i < l; i++) { const item = context[type][i]; // selector + // TODO: This is unnecessary if we properly normalize if (typeof item === 'string') { - const nodeList = Array.from(document.querySelectorAll(item)); + const nodeList = shadowSelectAll(item); result.push(...nodeList.map(node => getNodeFromTree(node))); break; } @@ -34,7 +36,7 @@ export function parseSelectorArray(context, type) { if (item.length > 1) { pushUniqueFrameSelector(context, type, item); } else { - const nodeList = Array.from(document.querySelectorAll(item[0])); + const nodeList = shadowSelectAll(item[0]); result.push(...nodeList.map(node => getNodeFromTree(node))); } } @@ -56,9 +58,9 @@ function pushUniqueFrameSelector(context, type, selectorArray) { context.frames = context.frames || []; const frameSelector = selectorArray.shift(); - const frames = document.querySelectorAll(frameSelector); - - Array.from(frames).forEach(frame => { + const frames = shadowSelectAll(frameSelector); + frames.forEach(frame => { + // TODO: This does not need to loop twice. This can work with just the .find context.frames.forEach(contextFrame => { if (contextFrame.node === frame) { contextFrame[type].push(selectorArray); diff --git a/lib/core/public/load.js b/lib/core/public/load.js index 02fce5e0cf..9719d5eb7d 100644 --- a/lib/core/public/load.js +++ b/lib/core/public/load.js @@ -13,6 +13,8 @@ function runCommand(data, keepalive, callback) { }; var context = (data && data.context) || {}; + // TODO: Could maybe replace this by using { include: [[":root"]] } + // in createFrameContext if (context.hasOwnProperty('include') && !context.include.length) { context.include = [document]; } diff --git a/lib/core/public/run-virtual-rule.js b/lib/core/public/run-virtual-rule.js index d474dcb112..c3057b5724 100644 --- a/lib/core/public/run-virtual-rule.js +++ b/lib/core/public/run-virtual-rule.js @@ -38,6 +38,7 @@ function runVirtualRule(ruleId, vNode, options = {}) { // elements rule = Object.create(rule, { excludeHidden: { value: false } }); + // TODO: This is missing props. Might be a problem const context = { initiator: true, include: [vNode] diff --git a/lib/core/public/run/normalize-run-params.js b/lib/core/public/run/normalize-run-params.js index 95b059a814..f6b5d1bba7 100644 --- a/lib/core/public/run/normalize-run-params.js +++ b/lib/core/public/run/normalize-run-params.js @@ -44,8 +44,11 @@ export default function normalizeRunParams([context, options, callback]) { } export function isContext(potential) { + // TODO: Need to allow labelled contexts here + // TODO: Should this function live somewhere else? switch (true) { case typeof potential === 'string': + // TODO: Should this allow other iterables? case Array.isArray(potential): case window.Node && potential instanceof window.Node: case window.NodeList && potential instanceof window.NodeList: diff --git a/lib/core/utils/contains.js b/lib/core/utils/contains.js index 90efa09680..dfea001e1f 100644 --- a/lib/core/utils/contains.js +++ b/lib/core/utils/contains.js @@ -7,33 +7,25 @@ * @return {Boolean} Whether `vNode` contains `otherVNode` */ export default function contains(vNode, otherVNode) { - /*eslint no-bitwise: 0*/ - if (vNode.shadowId || otherVNode.shadowId) { - do { - if (vNode.shadowId === otherVNode.shadowId) { - return true; - } - otherVNode = otherVNode.parent; - } while (otherVNode); - return false; + // Native light DOM method + if ( + !vNode.shadowId && + !otherVNode.shadowId && + vNode.actualNode && + typeof vNode.actualNode.contains === 'function' + ) { + return vNode.actualNode.contains(otherVNode.actualNode); } - if (!vNode.actualNode) { - // fallback for virtualNode only contexts - // @see https://github.com/Financial-Times/polyfill-service/pull/183/files - do { - if (otherVNode === vNode) { - return true; - } - otherVNode = otherVNode.parent; - } while (otherVNode); - } + // Alternative method for shadow DOM / virtual tree tests + do { + if (vNode === otherVNode) { + return true; + } else if (otherVNode.nodeIndex < vNode.nodeIndex) { + return false; + } + otherVNode = otherVNode.parent; + } while (otherVNode); - if (typeof vNode.actualNode.contains !== 'function') { - const position = vNode.actualNode.compareDocumentPosition( - otherVNode.actualNode - ); - return !!(position & 16); - } - return vNode.actualNode.contains(otherVNode.actualNode); + return false; } diff --git a/lib/core/utils/is-node-in-context.js b/lib/core/utils/is-node-in-context.js index c7af4489ba..391e07e0fc 100644 --- a/lib/core/utils/is-node-in-context.js +++ b/lib/core/utils/is-node-in-context.js @@ -23,6 +23,7 @@ function getDeepest(collection) { * @return {Boolean} [description] */ function isNodeInContext(node, context) { + // TODO: Tests that prove this works with shadow DOM const include = context.include && getDeepest( diff --git a/lib/core/utils/select.js b/lib/core/utils/select.js index 671ea31c22..3be91565ff 100644 --- a/lib/core/utils/select.js +++ b/lib/core/utils/select.js @@ -2,55 +2,13 @@ import contains from './contains'; import querySelectorAllFilter from './query-selector-all-filter'; import isNodeInContext from './is-node-in-context'; -/** - * Pushes unique nodes that are in context to an array - * @private - * @param {Array} result The array to push to - * @param {Array} nodes The list of nodes to push - * @param {Object} context The "resolved" context object, @see resolveContext - */ -function pushNode(result, nodes) { - let temp; - - if (result.length === 0) { - return nodes; - } - if (result.length < nodes.length) { - // switch so the comparison is shortest - temp = result; - result = nodes; - nodes = temp; - } - for (let i = 0, l = nodes.length; i < l; i++) { - if (!result.includes(nodes[i])) { - result.push(nodes[i]); - } - } - return result; -} - -/** - * reduces the includes list to only the outermost includes - * @private - * @param {Array} the array of include nodes - * @return {Array} the modified array of nodes - */ -function getOuterIncludes(includes) { - return includes.reduce((res, el) => { - if (!res.length || !contains(res[res.length - 1], el)) { - res.push(el); - } - return res; - }, []); -} - /** * Selects elements which match `selector` that are included and excluded via the `Context` object * @param {String} selector CSS selector of the HTMLElements to select * @param {Context} context The "resolved" context object, @see Context * @return {Array} Matching virtual DOM nodes sorted by DOM order */ -function select(selector, context) { +export default function select(selector, context) { let result = []; let candidate; if (axe._selectCache) { @@ -69,7 +27,7 @@ function select(selector, context) { for (let i = 0; i < outerIncludes.length; i++) { candidate = outerIncludes[i]; const nodes = querySelectorAllFilter(candidate, selector, isInContext); - result = pushNode(result, nodes); + result = mergeArrayUniques(result, nodes); } if (axe._selectCache) { axe._selectCache.push({ @@ -80,7 +38,20 @@ function select(selector, context) { return result; } -export default select; +/** + * reduces the includes list to only the outermost includes + * @private + * @param {Array} the array of include nodes + * @return {Array} the modified array of nodes + */ +function getOuterIncludes(includes) { + return includes.reduce((res, el) => { + if (!res.length || !contains(res[res.length - 1], el)) { + res.push(el); + } + return res; + }, []); +} /** * Return a filter method to test if a node is in context; or @@ -97,3 +68,27 @@ function getContextFilter(context) { } return node => isNodeInContext(node, context); } + +/** + * Merge the unique items from Array 1 into 2, or from 2 into 1 (whichever is longest) + * @private + * @param {Array} Arr1 + * @param {Array} Arr2 + */ +function mergeArrayUniques(arr1, arr2) { + if (arr1.length === 0) { + return arr2; + } + if (arr1.length < arr2.length) { + // switch so the comparison is shortest + const temp = arr1; + arr1 = arr2; + arr2 = temp; + } + for (let i = 0, l = arr2.length; i < l; i++) { + if (!arr1.includes(arr2[i])) { + arr1.push(arr2[i]); + } + } + return arr1; +} diff --git a/test/core/base/context.js b/test/core/base/context.js index 9098ba8a31..89c7f2a156 100644 --- a/test/core/base/context.js +++ b/test/core/base/context.js @@ -1,11 +1,13 @@ /*eslint no-unused-vars:0*/ describe('Context', () => { const { Context } = axe._thisWillBeDeletedDoNotUse.base; + const { createNestedShadowDom } = axe.testUtils; const fixture = document.getElementById('fixture'); function $id(id) { return document.getElementById(id); } + const ids = elms => elms.map(elm => `#${(elm?.actualNode || elm).id}`); it('should not mutate exclude in input', () => { fixture.innerHTML = '
'; @@ -32,11 +34,11 @@ describe('Context', () => { }; const context = new Context(spec); assert.notStrictEqual(spec.include, context.include); - spec.include.forEach(function (_, index) { + spec.include.forEach((_, index) => { assert.notStrictEqual(spec.include[index], context.include[index]); }); assert.notStrictEqual(spec.exclude, context.exclude); - spec.exclude.forEach(function (_, index) { + spec.exclude.forEach((_, index) => { assert.notStrictEqual(spec.exclude[index], context.exclude[index]); }); assert.notStrictEqual(spec.size, context.size); @@ -76,6 +78,31 @@ describe('Context', () => { assert.deepEqual([result.include[0].actualNode], [div]); }); + it('does not match shadow DOM nodes with light DOM selection', () => { + createNestedShadowDom( + fixture, + `

Light DOM

+
+

Slotted light DOM

+
`, + `
+

Shadow DOM

` + ); + const result = new Context([[['p']]]); + assert.deepEqual(ids(result.include), ['#p1', '#p2']); + }); + + it('accepts shadow DOM selectors', () => { + createNestedShadowDom( + fixture, + '
', + '
', + '

Hello world

' + ); + const result = new Context([[['#fixture > article', 'section', 'h1']]]); + assert.equal(result.include[0].props.id, 'target'); + }); + it('should accept a node reference consisting of nested divs', () => { const div1 = document.createElement('div'); const div2 = document.createElement('div'); @@ -231,8 +258,8 @@ describe('Context', () => { }); }); - describe('object definition', function () { - it('should assign include/exclude', function () { + describe('object definition', () => { + it('should assign include/exclude', () => { const context = new Context({ include: [['#fixture']], exclude: [['#mocha']] @@ -252,7 +279,7 @@ describe('Context', () => { assert.isAtLeast(context.flatTree.length, 1); }); - it('should disregard bad input, non-matching selectors', function () { + it('should disregard bad input, non-matching selectors', () => { const flatTree = axe.utils.getFlattenedTree(document); const context = new Context({ include: [['#fixture'], ['#monkeys']], @@ -276,7 +303,7 @@ describe('Context', () => { ); }); - it('should disregard bad input (null)', function () { + it('should disregard bad input (null)', () => { const result = new Context(); assert.lengthOf(result.include, 1); @@ -290,7 +317,7 @@ describe('Context', () => { assert.lengthOf(result.frames, 0); }); - it('should default include to document', function () { + it('should default include to document', () => { const result = new Context({ exclude: [['#fixture']] }); assert.lengthOf(result.include, 1); assert.equal(result.include[0].actualNode, document.documentElement); @@ -304,15 +331,15 @@ describe('Context', () => { assert.lengthOf(result.frames, 0); }); - it('should default empty include to document', function () { + it('should default empty include to document', () => { const result = new Context({ include: [], exclude: [] }); assert.lengthOf(result.include, 1); assert.equal(result.include[0].actualNode, document.documentElement); }); }); - describe('initiator', function () { - it('should not be clobbered', function () { + describe('initiator', () => { + it('should not be clobbered', () => { const result = new Context({ initiator: false }); @@ -328,7 +355,7 @@ describe('Context', () => { }); // document.hasOwnProperty is undefined in Firefox content scripts - it('should not throw given really weird circumstances when hasOwnProperty is deleted from a document node?', function () { + it('should not throw given really weird circumstances when hasOwnProperty is deleted from a document node?', () => { const spec = document.implementation.createHTMLDocument('ie is dumb'); spec.hasOwnProperty = undefined; @@ -346,20 +373,20 @@ describe('Context', () => { }); }); - describe('page', function () { - it('takes the page argument as default', function () { + describe('page', () => { + it('takes the page argument as default', () => { assert.isTrue(new Context({ page: true }).page); assert.isFalse(new Context({ page: false }).page); }); - it('is true if the document element is included', function () { + it('is true if the document element is included', () => { assert.isTrue(new Context(document).page); assert.isTrue(new Context(document.documentElement).page); assert.isTrue(new Context('html').page); assert.isTrue(new Context(':root').page); }); - it('is true, with exclude used', function () { + it('is true, with exclude used', () => { // What matters is that the documentElement is included // not that parts within that are excluded assert.isTrue( @@ -370,7 +397,7 @@ describe('Context', () => { ); }); - it('is false if the context does not include documentElement', function () { + it('is false if the context does not include documentElement', () => { assert.isFalse(new Context(fixture).page); assert.isFalse(new Context('#fixture').page); assert.isFalse(new Context([['#fixture']]).page); @@ -378,20 +405,20 @@ describe('Context', () => { }); }); - describe('focusable', function () { - it('should default to true', function () { + describe('focusable', () => { + it('should default to true', () => { const result = new Context(); assert.isTrue(result.focusable); }); - it('should use passed in value', function () { + it('should use passed in value', () => { const result = new Context({ focusable: false }); assert.isFalse(result.focusable); }); - it('should reject bad values', function () { + it('should reject bad values', () => { const result = new Context({ focusable: 'hello' }); @@ -399,13 +426,13 @@ describe('Context', () => { }); }); - describe('size', function () { - it('should default to empty object', function () { + describe('size', () => { + it('should default to empty object', () => { const result = new Context(); assert.deepEqual(result.size, {}); }); - it('should use passed in value', function () { + it('should use passed in value', () => { const result = new Context({ size: { width: 10, @@ -418,7 +445,7 @@ describe('Context', () => { }); }); - it('should reject bad values', function () { + it('should reject bad values', () => { const result = new Context({ size: 'hello' }); @@ -426,10 +453,10 @@ describe('Context', () => { }); }); - describe('frames', function () { + describe('frames', () => { function iframeReady(src, context, id, cb, done) { const iframe = document.createElement('iframe'); - iframe.addEventListener('load', function () { + iframe.addEventListener('load', () => { try { cb(iframe); done(); @@ -448,7 +475,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { const result = new Context('#target'); assert.lengthOf(result.frames, 1); assert.deepEqual(result.frames[0].node, $id('target')); @@ -463,7 +490,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { const result = new Context('#outer'); assert.lengthOf(result.frames, 1); assert.deepEqual(result.frames[0].node, $id('target')); @@ -478,7 +505,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { const result = new Context([['#target', '#foo']]); assert.lengthOf(result.frames, 1); @@ -496,7 +523,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { const result = new Context({ exclude: [['#target', '#foo']] }); @@ -515,7 +542,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('fixture'), 'target', - function () { + () => { const result = new Context(); assert.isTrue(result.initiator); assert.lengthOf(result.frames, 1); @@ -525,13 +552,62 @@ describe('Context', () => { ); }); - describe('.page', function () { + it('finds frames inside shadow DOM trees', () => { + createNestedShadowDom( + fixture, + '
', + '' + ); + const result = new Context(); + assert.equal(result.frames[0].node.id, 'target'); + }); + + it('accepts frames inside shadow DOM selectors', () => { + createNestedShadowDom( + fixture, + '
', + '
', + '' + ); + const result = new Context([ + [['#fixture > article', 'section', 'iframe'], ['h1']] + ]); + assert.equal(result.frames[0].node.id, 'target'); + }); + + it('skips frames excluded with shadow DOM selectors', () => { + createNestedShadowDom( + fixture, + '
', + '
', + '' + ); + const result = new Context({ + exclude: [[['#fixture > article', 'section', 'iframe']]] + }); + assert.isEmpty(result.frames); + }); + + it('skips frames in excluded shadow trees', () => { + createNestedShadowDom( + fixture, + '
', + '
', + '' + ); + const result = new Context({ + exclude: [[['#fixture > article', 'section']]] + }); + assert.isEmpty(result.frames); + }); + + describe('.page', () => { it('is true if context includes the document element', function (done) { iframeReady( '../mock/frames/context.html', $id('fixture'), 'target', - function () { + () => { const result = new Context({ exclude: [['#mocha']] }); @@ -547,7 +623,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('fixture'), 'target', - function () { + () => { const result = new Context({ include: [['#fixture']] }); @@ -559,7 +635,7 @@ describe('Context', () => { }); }); - describe('.focusable', function () { + describe('.focusable', () => { it('is true if tabindex is 0', function (done) { iframeReady( '../mock/frames/context.html', @@ -595,7 +671,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('fixture'), 'target', - function () { + () => { const result = new Context({ include: [['#fixture']], focusable: false @@ -608,7 +684,7 @@ describe('Context', () => { }); }); - describe('.size', function () { + describe('.size', () => { it('sets width and height of the frame', function (done) { iframeReady( '../mock/frames/context.html', @@ -649,7 +725,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { const result = new Context([ ['#target', '#foo'], ['#target', '#bar'] @@ -670,7 +746,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { const result = new Context([$id('target'), $id('target')]); assert.lengthOf(result.frames, 1); assert.deepEqual(result.frames[0].node, $id('target')); @@ -685,7 +761,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { const frame = $id('target'); frame.setAttribute('hidden', 'hidden'); @@ -702,7 +778,7 @@ describe('Context', () => { '../mock/frames/context.html', $id('outer'), 'target', - function () { + () => { assert.throws(function () { const ctxt = new Context(['#notAFrame', '#foo']); }); diff --git a/test/core/utils/contains.js b/test/core/utils/contains.js index a9d52f80b4..23b0eb8435 100644 --- a/test/core/utils/contains.js +++ b/test/core/utils/contains.js @@ -1,37 +1,68 @@ -describe('axe.utils.contains', function () { - 'use strict'; +describe('axe.utils.contains', () => { + const fixture = document.getElementById('fixture'); + const { fixtureSetup, createNestedShadowDom } = axe.testUtils; + + it('is true when the first node is an ancestor', () => { + const tree = fixtureSetup(`
`); + const node1 = axe.utils.querySelectorAll(tree, 'section')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'img')[0]; + assert.isTrue(axe.utils.contains(node1, node2)); + }); - var fixture = document.getElementById('fixture'); - var shadowSupported = axe.testUtils.shadowSupport.v1; + it('is false when the fitst node is a descendant', () => { + const tree = fixtureSetup(`
`); + const node1 = axe.utils.querySelectorAll(tree, 'img')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'section')[0]; + assert.isFalse(axe.utils.contains(node1, node2)); + }); + + it('is false when the nodes are siblings', () => { + const tree = fixtureSetup(`
`); + const node1 = axe.utils.querySelectorAll(tree, 'img')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'section')[0]; + assert.isFalse(axe.utils.contains(node1, node2)); + assert.isFalse(axe.utils.contains(node2, node1)); + }); - afterEach(function () { - fixture.innerHTML = ''; + it('is true when the passed the same node', () => { + const tree = fixtureSetup(``); + const node = axe.utils.querySelectorAll(tree, 'img')[0]; + assert.isTrue(axe.utils.contains(node, node)); }); - it('should first check contains', function () { - var success = false, - node2 = { actualNode: 'not really a node but it doesnt matter' }, - node1 = { + it('is false when the nodes are a cousins', () => { + const tree = fixtureSetup(`
`); + const node1 = axe.utils.querySelectorAll(tree, 'input')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'img')[0]; + assert.isFalse(axe.utils.contains(node1, node2)); + assert.isFalse(axe.utils.contains(node2, node1)); + }); + + describe.skip('using fallbacks', () => { + it('should first check DOMNode.contains', () => { + let success = false; + const node2 = { actualNode: 'not really a node but it doesnt matter' }; + const node1 = { actualNode: { contains: function (n2) { success = true; assert.deepEqual(n2, node2.actualNode); }, - compareDocumentPosition: function () { + compareDocumentPosition: () => { success = false; assert.ok(false, 'should not be called'); } } }; - axe.utils.contains(node1, node2); - assert.isTrue(success); - }); + axe.utils.contains(node1, node2); + assert.isTrue(success); + }); - it('should fallback to compareDocumentPosition', function () { - var success = false, - node2 = { actualNode: 'not really a node but it doesnt matter' }, - node1 = { + it('should fallback to compareDocumentPosition', () => { + let success = false; + const node2 = { actualNode: 'not really a node but it doesnt matter' }; + const node1 = { actualNode: { compareDocumentPosition: function (n2) { success = true; @@ -40,95 +71,112 @@ describe('axe.utils.contains', function () { } }; - axe.utils.contains(node1, node2); - assert.isTrue(success); - }); + axe.utils.contains(node1, node2); + assert.isTrue(success); + }); - it('should compareDocumentPosition against bitwise & 16', function () { - var node2 = { actualNode: 'not really a node but it doesnt matter' }, - node1 = { + it('should compareDocumentPosition against bitwise & 16', () => { + const node2 = { actualNode: 'not really a node but it doesnt matter' }; + const node1 = { actualNode: { - compareDocumentPosition: function () { + compareDocumentPosition: () => { return 20; } } }; - assert.isTrue(axe.utils.contains(node1, node2)); - }); + assert.isTrue(axe.utils.contains(node1, node2)); + }); - it('should fallback to parent lookup', function () { - var node1 = {}; - var node2 = { - parent: node1 - }; + it('should fallback to parent lookup', () => { + const node1 = {}; + const node2 = { + parent: node1 + }; - assert.isTrue(axe.utils.contains(node1, node2)); + assert.isTrue(axe.utils.contains(node1, node2)); + }); }); - (shadowSupported ? it : xit)( - 'should work when the child is inside shadow DOM', - function () { - var tree, node1, node2; - - function createContentContains() { - var group = document.createElement('div'); - group.innerHTML = - ''; - return group; - } - - function makeShadowTreeContains(node) { - var root = node.attachShadow({ mode: 'open' }); - var div = document.createElement('div'); - div.className = 'parent'; - root.appendChild(div); - div.appendChild(createContentContains()); - } - // shadow DOM v1 - note: v0 is compatible with this code, so no need - // to specifically test this - fixture.innerHTML = '
'; - makeShadowTreeContains(fixture.firstChild); - tree = axe.utils.getFlattenedTree(fixture.firstChild); - node1 = axe.utils.querySelectorAll(tree, '.parent')[0]; - node2 = axe.utils.querySelectorAll(tree, 'input')[0]; + describe('with shadow DOM', () => { + it('works when nodes are in the same tree', () => { + createNestedShadowDom( + fixture, + `
`, + `
` + ); + const tree = axe.setup(fixture); + const node1 = axe.utils.querySelectorAll(tree, 'section')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'img')[0]; assert.isTrue(axe.utils.contains(node1, node2)); - } - ); - - (shadowSupported ? it : xit)( - 'should work with slotted elements inside shadow DOM', - function () { - var tree, node1, node2; - - function createContentSlotted() { - var group = document.createElement('div'); - group.innerHTML = '
Stuff
'; - return group; - } - function makeShadowTree(node) { - var root = node.attachShadow({ mode: 'open' }); - var div = document.createElement('div'); - var a = document.createElement('a'); - div.appendChild(a); - root.appendChild(div); - div.appendChild(createContentSlotted()); - } - fixture.innerHTML = '
'; - makeShadowTree(fixture.firstChild); - tree = axe.utils.getFlattenedTree(fixture.firstChild)[0].children; - node1 = axe.utils.querySelectorAll(tree, '#target')[0]; - node2 = axe.utils.querySelectorAll(tree, 'a')[0]; + assert.isFalse(axe.utils.contains(node2, node1)); + }); + + it('works when the nodes are in nested trees', () => { + createNestedShadowDom( + fixture, + `
`, + `
` + ); + const tree = axe.setup(fixture); + const node1 = axe.utils.querySelectorAll(tree, 'section')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'img')[0]; assert.isTrue(axe.utils.contains(node1, node2)); - } - ); - - it('should work', function () { - fixture.innerHTML = '
'; - var inner = axe.utils.getFlattenedTree(document.getElementById('inner'))[0]; - var outer = axe.utils.getFlattenedTree(document.getElementById('outer'))[0]; + assert.isFalse(axe.utils.contains(node2, node1)); + }); + + it('works when the nodes are in nested multiple layers deep', () => { + createNestedShadowDom( + fixture, + `
`, + '
', + '
', + `
` + ); + const tree = axe.setup(fixture); + const node1 = axe.utils.querySelectorAll(tree, 'main')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'img')[0]; + assert.isTrue(axe.utils.contains(node1, node2)); + assert.isFalse(axe.utils.contains(node2, node1)); + }); + + it('is false when the nodes are in adjacent trees', () => { + const section = createNestedShadowDom( + fixture, + `
` + ); + createNestedShadowDom(section, ``); + createNestedShadowDom(section, ``); + const tree = axe.setup(fixture); + const node1 = axe.utils.querySelectorAll(tree, 'img')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'input')[0]; + + assert.isFalse(axe.utils.contains(node1, node2)); + assert.isFalse(axe.utils.contains(node2, node1)); + }); + + it('works with slotted elements inside shadow DOM', () => { + createNestedShadowDom( + fixture, + `
`, + `
` + ); + const tree = axe.setup(fixture); + const node1 = axe.utils.querySelectorAll(tree, 'section')[0]; + const node2 = axe.utils.querySelectorAll(tree, 'img')[0]; - assert.isTrue(axe.utils.contains(outer, inner)); - assert.isFalse(axe.utils.contains(inner, outer)); + assert.isTrue(axe.utils.contains(node1, node2)); + assert.isFalse(axe.utils.contains(node2, node1)); + }); + + it('should work', () => { + fixture.innerHTML = '
'; + const tree = axe.setup(fixture); + const inner = axe.utils.querySelectorAll(tree, '.inner')[0]; + const outer = axe.utils.querySelectorAll(tree, '.outer')[0]; + + assert.isTrue(axe.utils.contains(outer, inner)); + assert.isFalse(axe.utils.contains(inner, outer)); + }); }); }); diff --git a/test/core/utils/select.js b/test/core/utils/select.js index 8d0d9c7559..65eaabc37e 100644 --- a/test/core/utils/select.js +++ b/test/core/utils/select.js @@ -1,5 +1,6 @@ describe('axe.utils.select', function () { 'use strict'; + // TODO: Refactor function $id(id) { return document.getElementById(id); @@ -49,10 +50,11 @@ describe('axe.utils.select', function () { it('should exclude', function () { fixture.innerHTML = '
'; + axe.setup(); var result = axe.utils.select('.bananas', { - include: [axe.utils.getFlattenedTree($id('fixture'))[0]], - exclude: [axe.utils.getFlattenedTree($id('monkeys'))[0]] + include: [axe.utils.getNodeFromTree($id('fixture'))], + exclude: [axe.utils.getNodeFromTree($id('monkeys'))] }); assert.deepEqual(result, []); @@ -69,18 +71,18 @@ describe('axe.utils.select', function () { ' ' + ' ' + ''; + axe.setup(fixture); var result = axe.utils.select('.bananas', { include: [ - axe.utils.getFlattenedTree($id('include1'))[0], - axe.utils.getFlattenedTree($id('include2'))[0] + axe.utils.getNodeFromTree($id('include1')), + axe.utils.getNodeFromTree($id('include2')) ], exclude: [ - axe.utils.getFlattenedTree($id('exclude1'))[0], - axe.utils.getFlattenedTree($id('exclude2'))[0] + axe.utils.getNodeFromTree($id('exclude1')), + axe.utils.getNodeFromTree($id('exclude2')) ] }); - assert.deepEqual(result, []); }); @@ -97,16 +99,17 @@ describe('axe.utils.select', function () { ' ' + ' ' + ''; + axe.setup(); var result = axe.utils.select('.bananas', { include: [ - axe.utils.getFlattenedTree($id('include3'))[0], - axe.utils.getFlattenedTree($id('include2'))[0], - axe.utils.getFlattenedTree($id('include1'))[0] + axe.utils.getNodeFromTree($id('include3')), + axe.utils.getNodeFromTree($id('include2')), + axe.utils.getNodeFromTree($id('include1')) ], exclude: [ - axe.utils.getFlattenedTree($id('exclude1'))[0], - axe.utils.getFlattenedTree($id('exclude2'))[0] + axe.utils.getNodeFromTree($id('exclude1')), + axe.utils.getNodeFromTree($id('exclude2')) ] }); @@ -131,11 +134,12 @@ describe('axe.utils.select', function () { fixture.innerHTML = '
' + '
'; + axe.setup(); var result = axe.utils.select('.bananas', { include: [ - axe.utils.getFlattenedTree($id('zero'))[0], - axe.utils.getFlattenedTree($id('one'))[0] + axe.utils.getNodeFromTree($id('zero')), + axe.utils.getNodeFromTree($id('one')) ] }); diff --git a/test/integration/full/context/frames/shadow-frame.html b/test/integration/full/context/frames/shadow-frame.html new file mode 100644 index 0000000000..6a1e713830 --- /dev/null +++ b/test/integration/full/context/frames/shadow-frame.html @@ -0,0 +1,21 @@ + + + + Shadow frame + + + + +
+ +
+ + + diff --git a/test/integration/full/context/shadow-dom.html b/test/integration/full/context/shadow-dom.html new file mode 100644 index 0000000000..e3680a788e --- /dev/null +++ b/test/integration/full/context/shadow-dom.html @@ -0,0 +1,41 @@ + + + + frame exclude test + + + + + + + + + +
+ +
+ +
+ + + + + + + diff --git a/test/integration/full/context/shadow-dom.js b/test/integration/full/context/shadow-dom.js new file mode 100644 index 0000000000..76d953c57f --- /dev/null +++ b/test/integration/full/context/shadow-dom.js @@ -0,0 +1,45 @@ +describe('context test', () => { + before(done => { + axe.testUtils.awaitNestedLoad(done); + }); + + it('is able to include & exclude from frames in shadow DOM trees', async () => { + const { violations } = await axe.run( + { + include: [ + [ + ['#shadowHost', '#shadowFrame1'], + ['#shadowFrameHost', 'main'] + ], + [ + ['#shadowHost', '#shadowFrame2'], + ['#shadowFrameHost', 'main'] + ] + ], + exclude: [ + [ + ['#shadowHost', '#shadowFrame1'], + ['#shadowFrameHost', 'main aside'] + ], + [ + ['#shadowHost', '#shadowFrame2'], + ['#shadowFrameHost', 'main aside'] + ] + ] + }, + { runOnly: 'label' } + ); + + const targets = violations[0].nodes.map(({ target }) => target); + assert.deepEqual(targets, [ + [ + ['#shadowHost', '#shadowFrame1'], + ['#shadowFrameHost', '#fail'] + ], + [ + ['#shadowHost', '#shadowFrame2'], + ['#shadowFrameHost', '#fail'] + ] + ]); + }); +}); diff --git a/test/testutils.js b/test/testutils.js index 3c19aaff2b..e7d6bfc606 100644 --- a/test/testutils.js +++ b/test/testutils.js @@ -634,3 +634,19 @@ testUtils.shadowQuerySelector = function shadowQuerySelector(axeSelector, doc) { }); return elm; }; + +testUtils.createNestedShadowDom = function createFixtureShadowTree( + fixture, + ...htmlCodes +) { + let htmlCode; + while ((htmlCode = htmlCodes.shift())) { + fixture.innerHTML += htmlCode; + if (htmlCodes.length) { + const query = fixture.querySelectorAll('#shadowHost,.shadowHost'); + fixture = query[query.length - 1]; + fixture = fixture.attachShadow({ mode: 'open' }); + } + } + return fixture.querySelector('#target'); +}; From 56b91bc7af3fd887ac57a834451a5506d7704ea0 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Fri, 25 Nov 2022 15:55:20 +0100 Subject: [PATCH 02/14] Refactor Context code --- lib/core/base/context.js | 1 - lib/core/base/context/is-context.js | 24 +++ lib/core/base/context/normalize-context.js | 97 ++++++--- lib/core/public/load.js | 2 - lib/core/public/run-virtual-rule.js | 14 +- lib/core/public/run/normalize-run-params.js | 25 +-- lib/core/utils/is-node-in-context.js | 59 +++--- test/core/public/run-virtual-rule.js | 65 ++++-- test/core/utils/is-node-in-context.js | 45 ++++ test/core/utils/select.js | 222 ++++++++------------ 10 files changed, 308 insertions(+), 246 deletions(-) create mode 100644 lib/core/base/context/is-context.js create mode 100644 test/core/utils/is-node-in-context.js diff --git a/lib/core/base/context.js b/lib/core/base/context.js index 4984d79911..6375bc31f4 100644 --- a/lib/core/base/context.js +++ b/lib/core/base/context.js @@ -70,7 +70,6 @@ export default function Context(spec, flatTree) { if (!Array.isArray(this.include)) { this.include = Array.from(this.include); } - // TODO: Should I be sorting frames and exclude? this.include.sort(nodeSorter); // ensure that the order of the include nodes is document order } diff --git a/lib/core/base/context/is-context.js b/lib/core/base/context/is-context.js new file mode 100644 index 0000000000..c3eb47558f --- /dev/null +++ b/lib/core/base/context/is-context.js @@ -0,0 +1,24 @@ + +export function isContext(potential) { + // TODO: Need to allow labelled contexts here + switch (true) { + case typeof potential === 'string': + // TODO: Should this allow other iterables? + case Array.isArray(potential): + case window.Node && potential instanceof window.Node: + case window.NodeList && potential instanceof window.NodeList: + return true; + + case typeof potential !== 'object': + return false; + + case potential.include !== undefined: + case potential.exclude !== undefined: + case typeof potential.length === 'number': + return true; + + default: + return false; + } +} + diff --git a/lib/core/base/context/normalize-context.js b/lib/core/base/context/normalize-context.js index 550896fd33..1c470d238c 100644 --- a/lib/core/base/context/normalize-context.js +++ b/lib/core/base/context/normalize-context.js @@ -1,41 +1,78 @@ -/** - * Normalize the input of "context" so that many different methods of input are accepted - * @private - * @param {Mixed} context The configuration object passed to `Context` - * @return {Object} Normalized context spec to include both `include` and `exclude` arrays - */ -export function normalizeContext(context) { - if (!context) { - return defaultContext(); +export function normalizeContext(contextSpec) { + if (isContextProp(contextSpec)) { + contextSpec = { include: contextSpec } + } else if (!isContextObject(contextSpec)) { + // Context is invalid or undefined + return { include: [document], exclude: [] } } - if (typeof context === 'string') { - return defaultContext({ include: [[context]] }); - } - if (context instanceof window.Node) { - return defaultContext({ include: [context] }); + + const include = normalizeContextList(contextSpec.include); + if (include.length === 0) { + include.push(document); // Include defaults to [document] if empty } - if (context instanceof window.NodeList) { - return defaultContext({ include: context }); + const exclude = normalizeContextList(contextSpec.exclude); + return { include, exclude }; +} + +function normalizeContextList(selectorList = []) { + if (!isArrayLike(selectorList)) { + selectorList = [selectorList]; } - if (typeof context === 'object' && context.length === +context.length) { - // TODO: validate the content - return defaultContext({ include: context }); + + let normalizedList = []; + for (let i = 0; i < selectorList.length; i++) { + const normalizedSelector = normalizeContextSelector(selectorList[i]); + if (normalizedSelector) { + normalizedList.push(normalizedSelector) + } } + return normalizedList; +} +function normalizeContextSelector(selector) { + if (typeof selector === 'string') { + return [selector] // Convert to frame selector + } if ( - typeof context === 'object' && - (context.hasOwnProperty('include') || context.hasOwnProperty('exclude')) + selector instanceof window.Node || + isValidFrameSelector(selector) ) { - const exclude = - context.exclude && +context.exclude.length ? context.exclude : []; - const include = - context.include && +context.include.length ? context.include : [document]; - return { include, exclude }; + return selector + } + return +} + +function isContextObject(contextSpec) { + if (typeof contextSpec !== 'object') { + return false; } + return (isContextProp(contextSpec.include) || isContextProp(contextSpec.exclude)) +} - return defaultContext(); +function isContextProp(contextList) { + return ( + typeof contextList === 'string' || + contextList instanceof window.Node || + isArrayLike(contextList) + ) } -const defaultContext = ({ include = [document], exclude = [] } = {}) => { - return { include, exclude }; -}; +function isValidFrameSelector(selector) { + if (!Array.isArray(selector)) { + return false; + } + return selector.every(selectorItem => { + if (Array.isArray(selectorItem)) { + return selectorItem.every(shadowSelector => typeof shadowSelector === 'string') + } + return typeof selectorItem === 'string' + }) +} + +function isArrayLike(arr) { + return ( + typeof arr === 'object' && + typeof arr.length === 'number' && + arr instanceof window.Node === false // Avoid DOM weirdness + ); +} diff --git a/lib/core/public/load.js b/lib/core/public/load.js index 9719d5eb7d..02fce5e0cf 100644 --- a/lib/core/public/load.js +++ b/lib/core/public/load.js @@ -13,8 +13,6 @@ function runCommand(data, keepalive, callback) { }; var context = (data && data.context) || {}; - // TODO: Could maybe replace this by using { include: [[":root"]] } - // in createFrameContext if (context.hasOwnProperty('include') && !context.include.length) { context.include = [document]; } diff --git a/lib/core/public/run-virtual-rule.js b/lib/core/public/run-virtual-rule.js index c3057b5724..6c85fa2039 100644 --- a/lib/core/public/run-virtual-rule.js +++ b/lib/core/public/run-virtual-rule.js @@ -16,7 +16,7 @@ import { * @param {Object} options (optional) Set of options passed into rules or checks * @return {Object} axe results for the rule run */ -function runVirtualRule(ruleId, vNode, options = {}) { + export default function runVirtualRule(ruleId, vNode, options = {}) { // TODO: es-modules axe._audit // TODO: es-modules axe._selectorData options.reporter = options.reporter || axe._audit.reporter || 'v1'; @@ -37,11 +37,15 @@ function runVirtualRule(ruleId, vNode, options = {}) { // can avoid this call by forcing the rule to not exclude hidden // elements rule = Object.create(rule, { excludeHidden: { value: false } }); - - // TODO: This is missing props. Might be a problem const context = { initiator: true, - include: [vNode] + include: [vNode], + exclude: [], + frames: [], + page: false, + focusable: true, + size: {}, + flatTree: [] }; const rawResults = rule.runSync(context, options); @@ -61,5 +65,3 @@ function runVirtualRule(ruleId, vNode, options = {}) { toolOptions: options }; } - -export default runVirtualRule; diff --git a/lib/core/public/run/normalize-run-params.js b/lib/core/public/run/normalize-run-params.js index f6b5d1bba7..5aa67b3c8b 100644 --- a/lib/core/public/run/normalize-run-params.js +++ b/lib/core/public/run/normalize-run-params.js @@ -1,4 +1,5 @@ import { clone } from '../../utils'; +import { isContext } from '../../base/context/is-context'; /** * Normalize the optional params of axe.run() @@ -42,27 +43,3 @@ export default function normalizeRunParams([context, options, callback]) { options.reporter = options.reporter ?? axe._audit?.reporter ?? 'v1'; return { context, options, callback }; } - -export function isContext(potential) { - // TODO: Need to allow labelled contexts here - // TODO: Should this function live somewhere else? - switch (true) { - case typeof potential === 'string': - // TODO: Should this allow other iterables? - case Array.isArray(potential): - case window.Node && potential instanceof window.Node: - case window.NodeList && potential instanceof window.NodeList: - return true; - - case typeof potential !== 'object': - return false; - - case potential.include !== undefined: - case potential.exclude !== undefined: - case typeof potential.length === 'number': - return true; - - default: - return false; - } -} diff --git a/lib/core/utils/is-node-in-context.js b/lib/core/utils/is-node-in-context.js index 391e07e0fc..75ccb7a7f1 100644 --- a/lib/core/utils/is-node-in-context.js +++ b/lib/core/utils/is-node-in-context.js @@ -1,20 +1,5 @@ import contains from './contains'; -/** - * Get the deepest node in a given collection - * @private - * @param {Array} collection Array of nodes to test - * @return {Node} The deepest node - */ -function getDeepest(collection) { - return collection.sort((a, b) => { - if (contains(a, b)) { - return 1; - } - return -1; - })[0]; -} - /** * Determines if a node is included or excluded in a given context * @private @@ -22,26 +7,32 @@ function getDeepest(collection) { * @param {Object} context "Resolved" context object, @see resolveContext * @return {Boolean} [description] */ -function isNodeInContext(node, context) { - // TODO: Tests that prove this works with shadow DOM - const include = - context.include && - getDeepest( - context.include.filter(candidate => { - return contains(candidate, node); - }) - ); - const exclude = - context.exclude && - getDeepest( - context.exclude.filter(candidate => { - return contains(candidate, node); - }) - ); - if ((!exclude && include) || (exclude && contains(exclude, include))) { + export default function isNodeInContext(node, { include = [], exclude = []}) { + const filterInclude = include.filter(candidate => contains(candidate, node)); + if (filterInclude.length === 0) { + return false; + } + const filterExcluded = exclude.filter(candidate => contains(candidate, node)); + if (filterExcluded.length === 0) { return true; } - return false; + const deepestInclude = getDeepest(filterInclude); + const deepestExclude = getDeepest(filterExcluded); + return contains(deepestExclude, deepestInclude); } -export default isNodeInContext; +/** + * Get the deepest node in a given collection + * @private + * @param {Array} collection Array of nodes to test + * @return {Node} The deepest node + */ + function getDeepest(collection) { + let deepest + for (const node of collection) { + if (!deepest || !contains(node, deepest)) { + deepest = node + } + } + return deepest; +} diff --git a/test/core/public/run-virtual-rule.js b/test/core/public/run-virtual-rule.js index bc69eb0489..1462841027 100644 --- a/test/core/public/run-virtual-rule.js +++ b/test/core/public/run-virtual-rule.js @@ -1,3 +1,4 @@ +/*eslint no-unused-vars:0*/ describe('axe.runVirtualRule', function () { beforeEach(function () { axe._load({ @@ -89,26 +90,52 @@ describe('axe.runVirtualRule', function () { assert.isTrue(called); }); - it('should pass a virtual context to rule.runSync', function () { - var node = new axe.SerialVirtualNode({ nodeName: 'div' }); - axe._audit.rules = [ - { - id: 'aria-roles', - runSync: function (context) { - assert.equal(typeof context, 'object'); - assert.isTrue(Array.isArray(context.include)); - assert.equal(context.include[0], node); - - return { - id: 'aria-roles', - nodes: [] - }; + describe('context', () => { + const { Context } = axe._thisWillBeDeletedDoNotUse.base; + it('passes context with vNode included to rule.runSync', function () { + var node = new axe.SerialVirtualNode({ nodeName: 'div' }); + axe._audit.rules = [ + { + id: 'aria-roles', + runSync: function (context) { + assert.equal(typeof context, 'object'); + assert.isTrue(Array.isArray(context.include)); + assert.equal(context.include[0], node); + + return { + id: 'aria-roles', + nodes: [] + }; + } } - } - ]; - - axe.runVirtualRule('aria-roles', node); - }); + ]; + + axe.runVirtualRule('aria-roles', node); + }); + + it('has all properties a normal context has', () => { + const contextProps = Object.entries(new Context()) + .filter(([_, val]) => typeof val !== 'function') + .map(([key]) => key) + .sort(); + + var node = new axe.SerialVirtualNode({ nodeName: 'div' }); + axe._audit.rules = [ + { + id: 'aria-roles', + runSync: function (context) { + const virtualContextProps = Object.keys(context).sort(); + assert.deepEqual(virtualContextProps, contextProps); + return { + id: 'aria-roles', + nodes: [] + }; + } + } + ]; + axe.runVirtualRule('aria-roles', node); + }) + }) it('should pass through options to rule.runSync', function () { axe._audit.rules = [ diff --git a/test/core/utils/is-node-in-context.js b/test/core/utils/is-node-in-context.js new file mode 100644 index 0000000000..bc4bc53b04 --- /dev/null +++ b/test/core/utils/is-node-in-context.js @@ -0,0 +1,45 @@ +describe('axe.utils.isNodeInContext', () => { + const { queryFixture } = axe.testUtils; + const { Context } = axe._thisWillBeDeletedDoNotUse.base; + const { isNodeInContext } = axe.utils; + + it('is true when the node is included', () => { + const node = queryFixture( + `
` + ) + const context = new Context({ include: [['article']] }, axe._tree); + assert.isTrue(isNodeInContext(node, context)); + }); + + it('is false when the node is not included', () => { + const node = queryFixture( + `
` + ) + const context = new Context({ include: [['section']] }, axe._tree); + assert.isFalse(isNodeInContext(node, context)); + }); + + describe('when the node is excluded', () => { + it('is false when exclude is closer to the node than include', () => { + const node = queryFixture( + `
` + ) + const context = new Context({ + include: [['article']], + exclude: [['main'], ['section']], + }, axe._tree); + assert.isFalse(isNodeInContext(node, context)); + }); + + it('is true when include is closer to the node than exclude', () => { + const node = queryFixture( + `
` + ) + const context = new Context({ + include: [['main'], ['section']], + exclude: [['article']], + }, axe._tree); + assert.isTrue(isNodeInContext(node, context)); + }); + }); +}); diff --git a/test/core/utils/select.js b/test/core/utils/select.js index 65eaabc37e..54866a26cf 100644 --- a/test/core/utils/select.js +++ b/test/core/utils/select.js @@ -1,171 +1,133 @@ -describe('axe.utils.select', function () { - 'use strict'; - // TODO: Refactor +describe('axe.utils.select', () => { + const $id = id => document.getElementById(id); + const { Context } = axe._thisWillBeDeletedDoNotUse.base; + const { fixtureSetup } = axe.testUtils; - function $id(id) { - return document.getElementById(id); - } - - var fixture = document.getElementById('fixture'); - - afterEach(function () { - fixture.innerHTML = ''; - axe._selectCache = undefined; - }); - - it('should be a function', function () { + it('should be a function', () => { assert.isFunction(axe.utils.select); }); - it('should return an array', function () { + it('should return an array', () => { assert.isArray(axe.utils.select('div', { include: [] })); }); - describe('selector', function () { - it('should accept a selector', function () { - var div = document.createElement('div'); - div.id = 'monkeys'; - fixture.appendChild(div); - - var result = axe.utils.select('#monkeys', { - include: [axe.utils.getFlattenedTree(document)[0]] - }); - - assert.equal(result[0].actualNode, div); + describe('selector', () => { + it('should accept a selector', () => { + fixtureSetup('
') + const context = new Context(document, axe._tree) + const result = axe.utils.select('#monkeys', context); + assert.equal(result[0].actualNode, $id('monkeys')); }); }); - describe('context', function () { - it('should include', function () { - fixture.innerHTML = - '
'; - - var result = axe.utils.select('.bananas', { - include: [axe.utils.getFlattenedTree($id('monkeys'))[0]] - }); - - assert.deepEqual([result[0].actualNode], [$id('bananas')]); + describe('context', () => { + it('should include', () => { + fixtureSetup( + '
' + ); + const context = new Context('#monkeys', axe._tree); + const result = axe.utils.select('.bananas', context); + assert.deepEqual(result[0].actualNode, $id('bananas')); }); - it('should exclude', function () { - fixture.innerHTML = - '
'; - axe.setup(); - - var result = axe.utils.select('.bananas', { - include: [axe.utils.getNodeFromTree($id('fixture'))], - exclude: [axe.utils.getNodeFromTree($id('monkeys'))] - }); - - assert.deepEqual(result, []); + it('should exclude', () => { + fixtureSetup( + '
' + ); + const context = new Context({ + include: [['#fixture']], + exclude: [['#monkeys']] + }, axe._tree); + const result = axe.utils.select('.bananas', context); + assert.isEmpty(result); }); - it('should pick the deepest exclude/include - exclude winning', function () { - fixture.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
'; - axe.setup(fixture); - - var result = axe.utils.select('.bananas', { - include: [ - axe.utils.getNodeFromTree($id('include1')), - axe.utils.getNodeFromTree($id('include2')) - ], - exclude: [ - axe.utils.getNodeFromTree($id('exclude1')), - axe.utils.getNodeFromTree($id('exclude2')) - ] - }); + it('should pick the deepest exclude/include - exclude winning', () => { + fixtureSetup( + `
+
+
+
+
+
+
+
+
`) + const context = new Context({ + include: [['#include1'], ['#include2']], + exclude: [['#exclude1'], ['#exclude2']], + }, axe._tree); + const result = axe.utils.select('.bananas', context); assert.deepEqual(result, []); }); - it('should pick the deepest exclude/include - include winning', function () { - fixture.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
'; - axe.setup(); - - var result = axe.utils.select('.bananas', { - include: [ - axe.utils.getNodeFromTree($id('include3')), - axe.utils.getNodeFromTree($id('include2')), - axe.utils.getNodeFromTree($id('include1')) - ], - exclude: [ - axe.utils.getNodeFromTree($id('exclude1')), - axe.utils.getNodeFromTree($id('exclude2')) - ] - }); - - assert.deepEqual([result[0].actualNode], [$id('bananas')]); + it('should pick the deepest exclude/include - include winning', () => { + fixtureSetup( + `
+
+
+
+
+
+
+
+
+
+
` + ); + const context = new Context({ + include: [['#include3'], ['#include2'], ['#include1']], + exclude: [['#exclude1'], ['#exclude2']], + }, axe._tree); + const result = axe.utils.select('.bananas', context); + assert.deepEqual(result[0].actualNode, $id('bananas')); }); }); - it('should only contain unique elements', function () { - fixture.innerHTML = - '
'; - var tree = axe.utils.getFlattenedTree($id('fixture'))[0]; - var monkeys = tree.children[0]; - var result = axe.utils.select('.bananas', { - include: [tree, monkeys] - }); - + it('should only contain unique elements', () => { + fixtureSetup( + '
' + ); + const context = new Context({ + include: [['#fixture'], ['#monkeys']] + }, axe._tree) + + const result = axe.utils.select('.bananas', context); assert.lengthOf(result, 1); assert.equal(result[0].actualNode, $id('bananas')); }); - it('should not return duplicates on overlapping includes', function () { - fixture.innerHTML = + it('should not return duplicates on overlapping includes', () => { + fixtureSetup( '
' + - '
'; - axe.setup(); - - var result = axe.utils.select('.bananas', { - include: [ - axe.utils.getNodeFromTree($id('zero')), - axe.utils.getNodeFromTree($id('one')) - ] - }); - + '
' + ); + const context = new Context({ + include: [['#zero'], ['#one']] + }, axe._tree) + + const result = axe.utils.select('.bananas', context); assert.deepEqual( - result.map(function (n) { - return n.actualNode; - }), + result.map(n => n.actualNode), [$id('target1'), $id('target2')] ); assert.equal(result.length, 2); }); - it('should return the cached result if one exists', function () { - fixture.innerHTML = + it('should return the cached result if one exists', () => { + fixtureSetup( '
' + - '
'; - + '
' + ); + axe._selectCache = [ { selector: '.bananas', result: 'fruit bat' } ]; - var result = axe.utils.select('.bananas', { - include: [axe.utils.getFlattenedTree($id('zero'))[0]] - }); + const context = new Context([['#zero']], axe._tree); + const result = axe.utils.select('.bananas', context); assert.equal(result, 'fruit bat'); }); }); From 80c0ffd8653b245da3fc2a9d484c49d9b667de21 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 28 Nov 2022 13:47:16 +0100 Subject: [PATCH 03/14] Code complete --- lib/core/base/context/is-context.js | 24 -- lib/core/base/context/normalize-context.js | 162 ++++++-- lib/core/public/run.js | 4 +- lib/core/public/run/normalize-run-params.js | 4 +- test/core/base/context.js | 429 ++++++++++++++++---- test/core/public/run.js | 50 ++- 6 files changed, 521 insertions(+), 152 deletions(-) delete mode 100644 lib/core/base/context/is-context.js diff --git a/lib/core/base/context/is-context.js b/lib/core/base/context/is-context.js deleted file mode 100644 index c3eb47558f..0000000000 --- a/lib/core/base/context/is-context.js +++ /dev/null @@ -1,24 +0,0 @@ - -export function isContext(potential) { - // TODO: Need to allow labelled contexts here - switch (true) { - case typeof potential === 'string': - // TODO: Should this allow other iterables? - case Array.isArray(potential): - case window.Node && potential instanceof window.Node: - case window.NodeList && potential instanceof window.NodeList: - return true; - - case typeof potential !== 'object': - return false; - - case potential.include !== undefined: - case potential.exclude !== undefined: - case typeof potential.length === 'number': - return true; - - default: - return false; - } -} - diff --git a/lib/core/base/context/normalize-context.js b/lib/core/base/context/normalize-context.js index 1c470d238c..89ca13b163 100644 --- a/lib/core/base/context/normalize-context.js +++ b/lib/core/base/context/normalize-context.js @@ -1,9 +1,24 @@ +import { assert as utilsAssert } from '../../utils'; + +/** + * Normalize the input of "context" so that many different methods of input are accepted + * @private + * @param {Mixed} contextSpec The configuration object passed to `Context` + * @return {Object} Normalized context spec to include both `include` and `exclude` arrays + */ export function normalizeContext(contextSpec) { - if (isContextProp(contextSpec)) { - contextSpec = { include: contextSpec } - } else if (!isContextObject(contextSpec)) { - // Context is invalid or undefined - return { include: [document], exclude: [] } + if (isContextObject(contextSpec)) { + // Assert include / exclude isn't mixed with fromFrames / fromShadowDom + const msg = + ' must be used inside include or exclude. It should not be on the same object.'; + assert(!objectHasOwn(contextSpec, 'fromFrames'), 'fromFrames' + msg); + assert(!objectHasOwn(contextSpec, 'fromShadowDom'), 'fromShadowDom' + msg); + } else if (isContextProp(contextSpec)) { + // Wrap in include + contextSpec = { include: contextSpec, exclude: [] }; + } else { + // Spec is unknown + return { include: [document], exclude: [] }; } const include = normalizeContextList(contextSpec.include); @@ -14,65 +29,150 @@ export function normalizeContext(contextSpec) { return { include, exclude }; } +/** + * Determine if some value can be parsed as a context + * @private + * @param {Mixed} contextSpec The configuration object passed to `Context` + * @return {boolea} + */ +export function isContextSpec(contextSpec) { + return isContextObject(contextSpec) || isContextProp(contextSpec); +} + function normalizeContextList(selectorList = []) { + const normalizedList = []; if (!isArrayLike(selectorList)) { selectorList = [selectorList]; } - - let normalizedList = []; + // Use .length to handle jQuery-like objects for (let i = 0; i < selectorList.length; i++) { const normalizedSelector = normalizeContextSelector(selectorList[i]); if (normalizedSelector) { - normalizedList.push(normalizedSelector) + normalizedList.push(normalizedSelector); } } return normalizedList; } function normalizeContextSelector(selector) { + if (selector instanceof window.Node) { + return selector; // Nodes must not be wrapped in an array + } if (typeof selector === 'string') { - return [selector] // Convert to frame selector + return [selector]; // Convert to frame selector } - if ( - selector instanceof window.Node || - isValidFrameSelector(selector) - ) { - return selector + + if (isLabelledFramesSelector(selector)) { + assertLabelledFrameSelector(selector); + selector = selector.fromFrames; + } else if (isLabelledShadowDomSelector(selector)) { + selector = [selector]; } - return + return normalizeFrameSelectors(selector); } -function isContextObject(contextSpec) { - if (typeof contextSpec !== 'object') { - return false; +function normalizeFrameSelectors(frameSelectors) { + if (!Array.isArray(frameSelectors)) { + return; // Invalid. Skip this selector } - return (isContextProp(contextSpec.include) || isContextProp(contextSpec.exclude)) + const normalizedSelectors = []; + for (let selector of frameSelectors) { + if (isLabelledShadowDomSelector(selector)) { + assertLabelledShadowDomSelector(selector); + selector = selector.fromShadowDom; + } + if (typeof selector !== 'string' && !isShadowSelector(selector)) { + return; // Invalid. Skip this selector + } + normalizedSelectors.push(selector); + } + return normalizedSelectors; +} + +function isContextObject(contextSpec) { + return ['include', 'exclude'].some( + prop => objectHasOwn(contextSpec, prop) && isContextProp(contextSpec[prop]) + ); } function isContextProp(contextList) { return ( typeof contextList === 'string' || - contextList instanceof window.Node || + contextList instanceof window.Node || + isLabelledFramesSelector(contextList) || + isLabelledShadowDomSelector(contextList) || isArrayLike(contextList) - ) + ); } -function isValidFrameSelector(selector) { - if (!Array.isArray(selector)) { - return false; - } - return selector.every(selectorItem => { - if (Array.isArray(selectorItem)) { - return selectorItem.every(shadowSelector => typeof shadowSelector === 'string') - } - return typeof selectorItem === 'string' - }) +function isLabelledFramesSelector(selector) { + return objectHasOwn(selector, 'fromFrames'); +} + +function isLabelledShadowDomSelector(selector) { + return objectHasOwn(selector, 'fromShadowDom'); +} + +function assertLabelledFrameSelector(selector) { + assert( + Array.isArray(selector.fromFrames), + 'fromFrames property must be an array' + ); + assert( + selector.fromFrames.every( + selector => !objectHasOwn(selector, 'fromFrames') + ), + 'Invalid context; fromFrames selector must be appended, rather than nested' + ); + assert( + !objectHasOwn(selector, 'fromShadowDom'), + 'fromFrames and fromShadowDom cannot be used on the same object' + ); +} + +function assertLabelledShadowDomSelector(selector) { + assert( + Array.isArray(selector.fromShadowDom), + 'fromShadowDom property must be an array' + ); + assert( + selector.fromShadowDom.every( + selector => !objectHasOwn(selector, 'fromFrames') + ), + 'shadow selector must be inside fromFrame instead' + ); + assert( + selector.fromShadowDom.every( + selector => !objectHasOwn(selector, 'fromShadowDom') + ), + 'fromShadowDom selector must be appended, rather than nested' + ); +} + +function isShadowSelector(selector) { + return ( + Array.isArray(selector) && selector.every(str => typeof str === 'string') + ); } function isArrayLike(arr) { return ( + arr && typeof arr === 'object' && typeof arr.length === 'number' && arr instanceof window.Node === false // Avoid DOM weirdness ); } + +// Wrapper to ensure the correct message +function assert(bool, str) { + utilsAssert( + bool, + `Invalid context; ${str}\nSee: https://github.com/dequelabs/axe-core/blob/master/doc/context.md` + ); +} + +// Wrapper to prevent throwing for non-objects & null +function objectHasOwn(obj, prop) { + return obj && typeof obj === 'object' && Object.hasOwn(obj, prop); +} diff --git a/lib/core/public/run.js b/lib/core/public/run.js index 0f85843391..b5bdbd40e9 100644 --- a/lib/core/public/run.js +++ b/lib/core/public/run.js @@ -1,5 +1,5 @@ import { getReporter } from './reporter'; -import normalizeRunArgs from './run/normalize-run-params'; +import normalizeRunParams from './run/normalize-run-params'; import { setupGlobals, resetGlobals } from './run/globals-setup'; import { assert } from '../utils'; @@ -18,7 +18,7 @@ const noop = () => {}; */ export default function run(...args) { setupGlobals(args[0]); - const { context, options, callback = noop } = normalizeRunArgs(args); + const { context, options, callback = noop } = normalizeRunParams(args); const { thenable, resolve, reject } = getPromiseHandlers(callback); try { assert(axe._audit, 'No audit configured'); diff --git a/lib/core/public/run/normalize-run-params.js b/lib/core/public/run/normalize-run-params.js index 5aa67b3c8b..96c4b18f4e 100644 --- a/lib/core/public/run/normalize-run-params.js +++ b/lib/core/public/run/normalize-run-params.js @@ -1,5 +1,5 @@ import { clone } from '../../utils'; -import { isContext } from '../../base/context/is-context'; +import { isContextSpec } from '../../base/context/normalize-context'; /** * Normalize the optional params of axe.run() @@ -12,7 +12,7 @@ export default function normalizeRunParams([context, options, callback]) { const typeErr = new TypeError('axe.run arguments are invalid'); // Determine the context - if (!isContext(context)) { + if (!isContextSpec(context)) { if (callback !== undefined) { // Either context is invalid or there are too many params throw typeErr; diff --git a/test/core/base/context.js b/test/core/base/context.js index 89c7f2a156..4245014122 100644 --- a/test/core/base/context.js +++ b/test/core/base/context.js @@ -1,4 +1,4 @@ -/*eslint no-unused-vars:0*/ +/*eslint no-new:0*/ describe('Context', () => { const { Context } = axe._thisWillBeDeletedDoNotUse.base; const { createNestedShadowDom } = axe.testUtils; @@ -7,7 +7,11 @@ describe('Context', () => { function $id(id) { return document.getElementById(id); } - const ids = elms => elms.map(elm => `#${(elm?.actualNode || elm).id}`); + const selectors = elms => + elms.map(elm => { + const domNode = elm?.actualNode || elm; + return domNode.id ? `#${domNode.id}` : domNode.nodeName.toLowerCase(); + }); it('should not mutate exclude in input', () => { fixture.innerHTML = '
'; @@ -52,29 +56,31 @@ describe('Context', () => { }); describe('include', () => { - it('should accept a single selector', () => { + it('accepts a single selector', () => { fixture.innerHTML = '
'; const result = new Context('#foo'); - - assert.deepEqual([result.include[0].actualNode], [$id('foo')]); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.isEmpty(result.exclude); }); - it('should accept multiple selectors', () => { + it('accepts frame selectors', () => { fixture.innerHTML = '
'; const result = new Context([['#foo'], ['#bar']]); + assert.deepEqual(selectors(result.include), ['#foo', '#bar']); + assert.isEmpty(result.exclude); + }); - assert.deepEqual( - [result.include[0].actualNode, result.include[1].actualNode], - [$id('foo'), $id('bar')] - ); + it('accepts an array of strings as selectors', () => { + fixture.innerHTML = '
'; + const context = new Context(['#foo', '#bar']); + assert.deepEqual(selectors(context.include), ['#foo', '#bar']); + assert.isEmpty(context.exclude); }); - it('should accept a node reference', () => { + it('accepts a node reference', () => { const div = document.createElement('div'); fixture.appendChild(div); - const result = new Context(div); - assert.deepEqual([result.include[0].actualNode], [div]); }); @@ -89,7 +95,7 @@ describe('Context', () => {

Shadow DOM

` ); const result = new Context([[['p']]]); - assert.deepEqual(ids(result.include), ['#p1', '#p2']); + assert.deepEqual(selectors(result.include), ['#p1', '#p2']); }); it('accepts shadow DOM selectors', () => { @@ -103,19 +109,17 @@ describe('Context', () => { assert.equal(result.include[0].props.id, 'target'); }); - it('should accept a node reference consisting of nested divs', () => { + it('accepts a node reference consisting of nested divs', () => { const div1 = document.createElement('div'); const div2 = document.createElement('div'); - div1.appendChild(div2); fixture.appendChild(div1); const result = new Context(div1); - assert.deepEqual([result.include[0].actualNode], [div1]); }); - it('should accept a node reference consisting of a form with nested controls', () => { + it('accepts a node reference consisting of a form with nested controls', () => { const form = document.createElement('form'); const input = document.createElement('input'); @@ -123,78 +127,48 @@ describe('Context', () => { fixture.appendChild(form); const result = new Context(form); - - assert.deepEqual([result.include[0].actualNode], [form]); + assert.deepEqual(result.include[0].actualNode, form); }); - it('should accept an array of node references', () => { + it('accepts an array of node references', () => { fixture.innerHTML = '
'; - const result = new Context([$id('foo'), $id('bar')]); - assert.deepEqual( [result.include[0].actualNode, result.include[1].actualNode], [$id('foo'), $id('bar')] ); }); - it('should remove any non-matched reference', () => { + it('removes any non-matched reference', () => { fixture.innerHTML = '
'; - const result = new Context([['#foo'], ['#baz'], ['#bar']]); - - assert.deepEqual( - result.include.map(function (n) { - return n.actualNode; - }), - [$id('foo'), $id('bar')] - ); + assert.deepEqual(selectors(result.include), ['#foo', '#bar']); }); - it('should sort the include nodes in document order', () => { + it('sorts the include nodes in document order', () => { fixture.innerHTML = '
'; - const result = new Context([['#foo'], ['#baz'], ['#bar']]); - - assert.deepEqual( - result.include.map(function (n) { - return n.actualNode; - }), - [$id('foo'), $id('bar'), $id('baz')] - ); + assert.deepEqual(selectors(result.include), ['#foo', '#bar', '#baz']); }); - it('should remove any null reference', () => { + it('removes any null reference', () => { fixture.innerHTML = '
'; - const result = new Context([$id('foo'), $id('bar'), null]); - - assert.deepEqual( - result.include.map(function (n) { - return n.actualNode; - }), - [$id('foo'), $id('bar')] - ); + assert.deepEqual(selectors(result.include), ['#foo', '#bar']); }); - it('should accept mixed', () => { + it('accepts an array of mixed selectors and nodes', () => { fixture.innerHTML = '
'; const div = document.createElement('div'); div.id = 'baz'; fixture.appendChild(div); - const result = new Context([['#foo'], ['#bar'], div]); - - assert.deepEqual( - result.include.map(function (n) { - return n.actualNode; - }), - [$id('foo'), $id('bar'), $id('baz')] - ); + const result = new Context([['#foo'], '#bar', div]); + assert.deepEqual(selectors(result.include), ['#foo', '#bar', '#baz']); }); - it('should support jQuery-like objects', () => { + it('accepts jQuery-like objects', () => { fixture.innerHTML = '
'; const $test = { @@ -205,13 +179,7 @@ describe('Context', () => { }; const result = new Context($test); - - assert.deepEqual( - result.include.map(function (n) { - return n.actualNode; - }), - [$id('foo'), $id('bar'), $id('baz')] - ); + assert.deepEqual(selectors(result.include), ['#foo', '#bar', '#baz']); }); describe('throwing errors', () => { @@ -228,7 +196,7 @@ describe('Context', () => { fixture.innerHTML = '
'; assert.throws( () => { - const ctxt = new Context('#notAnElement'); + new Context('#notAnElement'); }, Error, 'No elements found for include in page Context' @@ -243,7 +211,7 @@ describe('Context', () => { fixture.innerHTML = '
'; assert.throws( () => { - const ctxt = new Context('#notAnElement'); + new Context('#notAnElement'); }, Error, 'No elements found for include in frame Context' @@ -251,7 +219,7 @@ describe('Context', () => { }); }); - it('should create a flatTree property', () => { + it('creates a flatTree property', () => { const context = new Context({ include: [document] }); assert.isArray(context.flatTree); assert.isAtLeast(context.flatTree.length, 1); @@ -309,12 +277,12 @@ describe('Context', () => { assert.lengthOf(result.include, 1); assert.equal(result.include[0].actualNode, document.documentElement); - assert.lengthOf(result.exclude, 0); + assert.isEmpty(result.exclude); assert.isTrue(result.initiator); assert.isTrue(result.page); - assert.lengthOf(result.frames, 0); + assert.isEmpty(result.frames); }); it('should default include to document', () => { @@ -328,7 +296,7 @@ describe('Context', () => { assert.isTrue(result.initiator); assert.isTrue(result.page); - assert.lengthOf(result.frames, 0); + assert.isEmpty(result.frames); }); it('should default empty include to document', () => { @@ -336,6 +304,24 @@ describe('Context', () => { assert.lengthOf(result.include, 1); assert.equal(result.include[0].actualNode, document.documentElement); }); + + it('throws when it has a fromFrames prop', () => { + assert.throws(() => { + new Context({ + include: [], + fromFrames: ['frame', '#fixture'] + }); + }); + }); + + it('throws when it has a fromShadowDom prop', () => { + assert.throws(() => { + new Context({ + include: [], + fromShadowDom: ['frame', '#fixture'] + }); + }); + }); }); describe('initiator', () => { @@ -346,12 +332,10 @@ describe('Context', () => { assert.lengthOf(result.include, 1); assert.equal(result.include[0].actualNode, document.documentElement); - assert.lengthOf(result.exclude, 0); - + assert.isEmpty(result.exclude); assert.isFalse(result.initiator); assert.isTrue(result.page); - - assert.lengthOf(result.frames, 0); + assert.isEmpty(result.frames); }); // document.hasOwnProperty is undefined in Firefox content scripts @@ -364,12 +348,12 @@ describe('Context', () => { assert.lengthOf(result.include, 1); assert.equal(result.include[0].actualNode, spec.documentElement); - assert.lengthOf(result.exclude, 0); + assert.isEmpty(result.exclude); assert.isTrue(result.initiator); assert.isFalse(result.page); - assert.lengthOf(result.frames, 0); + assert.isEmpty(result.frames); }); }); @@ -780,11 +764,296 @@ describe('Context', () => { 'target', () => { assert.throws(function () { - const ctxt = new Context(['#notAFrame', '#foo']); + new Context(['#notAFrame', '#foo']); }); }, done ); }); }); + + describe('with labelled fame selectors', () => { + it('accepts a single labelled selector', () => { + fixture.innerHTML = '
'; + const result = new Context({ + fromFrames: ['#foo'] + }); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.isEmpty(result.exclude); + }); + + it('accepts multiple labelled selectors', () => { + fixture.innerHTML = '
'; + const result = new Context([ + { fromFrames: ['#foo'] }, + { fromFrames: ['#bar'] } + ]); + assert.deepEqual(selectors(result.include), ['#foo', '#bar']); + assert.isEmpty(result.exclude); + }); + + it('accepts labelled selectors on include & exclude', () => { + fixture.innerHTML = '
'; + const result = new Context({ + include: [{ fromFrames: ['#foo'] }], + exclude: { fromFrames: ['#bar'] } + }); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.deepEqual(selectors(result.exclude), ['#bar']); + }); + + it('throws when not passed an array', () => { + assert.throws(() => { + new Context({ fromFrames: '#fixture' }); + }); + }); + + it('throws when fromShadowDom is on the same object', () => { + assert.throws(() => { + new Context({ + fromFrames: ['#fixture'], + fromShadowDom: ['#fixture'] + }); + }); + }); + + it('throws when labelled frames are nested', () => { + assert.throws(() => { + new Context({ + fromFrames: ['#fixture', { fromFrames: ['#fixture'] }] + }); + }); + }); + + describe('when the selector has length > 1', () => { + it('sets the frame, rather than include / exclude', () => { + fixture.innerHTML = ``; + const result = new Context({ + include: { fromFrames: ['#foo', 'h1'] }, + exclude: { fromFrames: ['iframe', 'img'] } + }); + assert.isEmpty(result.include); + assert.isEmpty(result.exclude); + assert.lengthOf(result.frames, 1); + }); + + it('creates a context for the frame', () => { + fixture.innerHTML = ``; + const result = new Context({ + include: { fromFrames: ['#foo', 'h1'] }, + exclude: { fromFrames: ['iframe', 'img'] } + }); + const frameContext = result.frames[0]; + assert.lengthOf(result.frames, 1); + assert.deepEqual(frameContext.include, [['h1']]); + assert.deepEqual(frameContext.exclude, [['img']]); + }); + }); + }); + + describe('with labelled shadow DOM selectors', () => { + it('accepts a single labelled selector', () => { + createNestedShadowDom( + fixture, + '
', + '
', + '

Heading

' + ); + const result = new Context({ + fromShadowDom: ['#fixture > article', 'section', 'h1'] + }); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.isEmpty(result.exclude); + assert.isEmpty(result.frames); + }); + + it('accepts multiple labelled selectors', () => { + createNestedShadowDom( + fixture, + '
', + '
', + '
' + ); + const result = new Context([ + { fromShadowDom: ['#fixture > article', 'section', '#foo'] }, + { fromShadowDom: ['#fixture > article', 'section', '#bar'] } + ]); + assert.deepEqual(selectors(result.include), ['#foo', '#bar']); + assert.isEmpty(result.exclude); + assert.isEmpty(result.frames); + }); + + it('accepts labelled selectors on include & exclude', () => { + createNestedShadowDom( + fixture, + '
', + '

' + ); + const result = new Context({ + include: [{ fromShadowDom: ['#fixture > article', 'h1'] }], + exclude: { fromShadowDom: ['#fixture > article', 'h2'] } + }); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.deepEqual(selectors(result.exclude), ['#bar']); + }); + + it('throws when fromShadowDom does not contain an array', () => { + fixture.innerHTML = '

Hello World

'; + assert.throws(() => { + new Context({ fromShadowDom: 'h1' }); + }); + }); + + it('throws when containing a labelled frame selector', () => { + createNestedShadowDom( + fixture, + '
', + `` + ); + assert.throws(() => { + new Context({ + fromShadowDom: [ + '#fixture > article', + { fromFrames: ['iframe', 'h1'] } + ] + }); + }); + }); + + it('throws when containing another labelled shadow dom selector', () => { + createNestedShadowDom( + fixture, + '
', + '
', + '

Heading

' + ); + assert.throws(() => { + new Context({ + fromShadowDom: [ + '#fixture > article', + { fromShadowDom: ['section', 'h1'] } + ] + }); + }); + }); + + describe('nested in a frame selector', () => { + it('works for unlabelled frame selectors', () => { + createNestedShadowDom( + fixture, + '
', + `` + ); + const result = new Context([ + [ + { + fromShadowDom: ['#fixture > article', 'iframe'] + }, + ['h1'] + ] + ]); + + const frameNodes = result.frames.map(({ node }) => node); + assert.deepEqual(selectors(frameNodes), ['#foo']); + }); + + it('works for labelled frame selectors', () => { + createNestedShadowDom( + fixture, + '
', + `` + ); + const result = new Context({ + fromFrames: [ + { + fromShadowDom: ['#fixture > article', 'iframe'] + }, + ['h1'] + ] + }); + const frameNodes = result.frames.map(({ node }) => node); + assert.deepEqual(selectors(frameNodes), ['#foo']); + }); + }); + }); + + describe('ignores bad values', () => { + it('passed directly into include', () => { + const result = new Context([null, fixture, false, {}]); + assert.deepEqual(selectors(result.include), ['#fixture']); + assert.isEmpty(result.exclude); + }); + + it('in unlabelled frame selectors', () => { + fixture.innerHTML = '
'; + const result = new Context([ + [null], + ['#fixture > article'], + [fixture], + [false], + [{}] + ]); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.isEmpty(result.exclude); + }); + + it('in labelled frame selectors', () => { + fixture.innerHTML = '
'; + const result = new Context([ + { fromFrames: [null] }, + { fromFrames: ['#fixture > article'] }, + { fromFrames: [fixture] }, + { fromFrames: [false] }, + { fromFrames: [{}] } + ]); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.isEmpty(result.exclude); + }); + + it('in unlabelled shadow DOM selectors', () => { + createNestedShadowDom( + fixture, + '
', + '

' + ); + const result = new Context([ + [['#fixture > article', null]], + [['#fixture > article', 'h1']], // Valid + [['#fixture > article', ['h2']]], + [['#fixture > article', fixture]], + [['#fixture > article', false]], + [['#fixture > article', {}]] + ]); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.isEmpty(result.exclude); + }); + + it('in unlabelled shadow DOM selectors', () => { + createNestedShadowDom( + fixture, + '
', + '

' + ); + const result = new Context([ + { fromShadowDom: ['#fixture > article', null] }, + { fromShadowDom: ['#fixture > article', 'h1'] }, // valid + { fromShadowDom: ['#fixture > article', ['h1']] }, + { fromShadowDom: ['#fixture > article', fixture] }, + { fromShadowDom: ['#fixture > article', false] }, + { fromShadowDom: ['#fixture > article', {}] } + ]); + assert.deepEqual(selectors(result.include), ['#foo']); + assert.isEmpty(result.exclude); + }); + }); }); diff --git a/test/core/public/run.js b/test/core/public/run.js index fd46491e52..39cdd9d433 100644 --- a/test/core/public/run.js +++ b/test/core/public/run.js @@ -80,22 +80,46 @@ describe('axe.run', function () { }); }); - it('treats objects with include or exclude as the context object', function (done) { - axe._runRules = function (ctxt) { - assert.deepEqual(ctxt, { include: '#BoggyB' }); - done(); - }; + describe('identifies context objects', () => { + it('based on the include property', done => { + axe._runRules = ctxt => { + assert.deepEqual(ctxt, { include: '#BoggyB' }); + done(); + }; + axe.run({ include: '#BoggyB' }, noop); + }); - axe.run({ include: '#BoggyB' }, noop); - }); + it('based on the exclude property', done => { + axe._runRules = ctxt => { + assert.deepEqual(ctxt, { exclude: '#BoggyB' }); + done(); + }; + axe.run({ exclude: '#BoggyB' }, noop); + }); - it('treats objects with neither include or exclude as the option object', function (done) { - axe._runRules = function (ctxt, opt) { - assert.deepEqual(opt.HHG, 'hallelujah'); - done(); - }; + it('based on the fromFrames property', done => { + axe._runRules = ctxt => { + assert.deepEqual(ctxt, { fromFrames: ['#myFrame'] }); + done(); + }; + axe.run({ fromFrames: ['#myFrame'] }, noop); + }); - axe.run({ HHG: 'hallelujah' }, noop); + it('based on the fromShadowDom property', done => { + axe._runRules = ctxt => { + assert.deepEqual(ctxt, { fromShadowDom: ['#myFrame'] }); + done(); + }; + axe.run({ fromShadowDom: ['#myFrame'] }, noop); + }); + + it('ignores objects with none of those properties', done => { + axe._runRules = (ctxt, opt) => { + assert.deepEqual(opt.HHG, 'hallelujah'); + done(); + }; + axe.run({ HHG: 'hallelujah' }, noop); + }); }); it('does not fail if no callback is specified', function (done) { From ecbc23253330923a41bb2f36ab43239b9ccbedd7 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 28 Nov 2022 14:14:17 +0100 Subject: [PATCH 04/14] cleanup --- lib/core/base/context/parse-selector-array.js | 34 +++--------- test/core/public/run-virtual-rule.js | 13 +++-- test/core/utils/is-node-in-context.js | 53 ++++++++++++++----- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/lib/core/base/context/parse-selector-array.js b/lib/core/base/context/parse-selector-array.js index bc228542f6..4435664f41 100644 --- a/lib/core/base/context/parse-selector-array.js +++ b/lib/core/base/context/parse-selector-array.js @@ -9,18 +9,9 @@ import { getNodeFromTree, shadowSelectAll } from '../../utils'; * @return {Array} Parsed array of matching elements */ export function parseSelectorArray(context, type) { - // TODO: instead of modifying context, this should return things context can used to modify itself const result = []; for (let i = 0, l = context[type].length; i < l; i++) { const item = context[type][i]; - // selector - // TODO: This is unnecessary if we properly normalize - if (typeof item === 'string') { - const nodeList = shadowSelectAll(item); - result.push(...nodeList.map(node => getNodeFromTree(node))); - break; - } - // Handle nodes if (item instanceof window.Node) { if (item.documentElement instanceof window.Node) { @@ -28,11 +19,9 @@ export function parseSelectorArray(context, type) { } else { result.push(getNodeFromTree(item)); } - continue; - } - // Handle Iframe selection - if (item && item.length) { + // Handle Iframe selection + } else if (item && item.length) { if (item.length > 1) { pushUniqueFrameSelector(context, type, item); } else { @@ -60,20 +49,11 @@ function pushUniqueFrameSelector(context, type, selectorArray) { const frameSelector = selectorArray.shift(); const frames = shadowSelectAll(frameSelector); frames.forEach(frame => { - // TODO: This does not need to loop twice. This can work with just the .find - context.frames.forEach(contextFrame => { - if (contextFrame.node === frame) { - contextFrame[type].push(selectorArray); - } - }); - - if (!context.frames.find(result => result.node === frame)) { - const result = createFrameContext(frame, context); - if (selectorArray) { - result[type].push(selectorArray); - } - - context.frames.push(result); + let frameContext = context.frames.find(result => result.node === frame); + if (!frameContext) { + frameContext = createFrameContext(frame, context); + context.frames.push(frameContext); } + frameContext[type].push(selectorArray); }); } diff --git a/test/core/public/run-virtual-rule.js b/test/core/public/run-virtual-rule.js index 1462841027..8ceda376a7 100644 --- a/test/core/public/run-virtual-rule.js +++ b/test/core/public/run-virtual-rule.js @@ -1,4 +1,3 @@ -/*eslint no-unused-vars:0*/ describe('axe.runVirtualRule', function () { beforeEach(function () { axe._load({ @@ -101,7 +100,7 @@ describe('axe.runVirtualRule', function () { assert.equal(typeof context, 'object'); assert.isTrue(Array.isArray(context.include)); assert.equal(context.include[0], node); - + return { id: 'aria-roles', nodes: [] @@ -109,13 +108,13 @@ describe('axe.runVirtualRule', function () { } } ]; - + axe.runVirtualRule('aria-roles', node); }); - + it('has all properties a normal context has', () => { const contextProps = Object.entries(new Context()) - .filter(([_, val]) => typeof val !== 'function') + .filter(arg => typeof arg[1] !== 'function') .map(([key]) => key) .sort(); @@ -134,8 +133,8 @@ describe('axe.runVirtualRule', function () { } ]; axe.runVirtualRule('aria-roles', node); - }) - }) + }); + }); it('should pass through options to rule.runSync', function () { axe._audit.rules = [ diff --git a/test/core/utils/is-node-in-context.js b/test/core/utils/is-node-in-context.js index bc4bc53b04..0d99f7d94f 100644 --- a/test/core/utils/is-node-in-context.js +++ b/test/core/utils/is-node-in-context.js @@ -1,4 +1,4 @@ -describe('axe.utils.isNodeInContext', () => { +describe('axe.utils.isNodeInContext', () => { const { queryFixture } = axe.testUtils; const { Context } = axe._thisWillBeDeletedDoNotUse.base; const { isNodeInContext } = axe.utils; @@ -6,7 +6,7 @@ describe('axe.utils.isNodeInContext', () => { it('is true when the node is included', () => { const node = queryFixture( `
` - ) + ); const context = new Context({ include: [['article']] }, axe._tree); assert.isTrue(isNodeInContext(node, context)); }); @@ -14,7 +14,7 @@ describe('axe.utils.isNodeInContext', () => { it('is false when the node is not included', () => { const node = queryFixture( `
` - ) + ); const context = new Context({ include: [['section']] }, axe._tree); assert.isFalse(isNodeInContext(node, context)); }); @@ -23,23 +23,50 @@ describe('axe.utils.isNodeInContext', () => { it('is false when exclude is closer to the node than include', () => { const node = queryFixture( `
` - ) - const context = new Context({ - include: [['article']], - exclude: [['main'], ['section']], - }, axe._tree); + ); + const context = new Context( + { + include: [['article']], + exclude: [['main'], ['section']] + }, + axe._tree + ); assert.isFalse(isNodeInContext(node, context)); }); it('is true when include is closer to the node than exclude', () => { const node = queryFixture( `
` - ) - const context = new Context({ - include: [['main'], ['section']], - exclude: [['article']], - }, axe._tree); + ); + const context = new Context( + { + include: [['main'], ['section']], + exclude: [['article']] + }, + axe._tree + ); assert.isTrue(isNodeInContext(node, context)); }); }); + + describe('when nodeIndex is undefined', () => { + it('is true when the node is included', () => { + const node = queryFixture(`
`); + delete node.nodeIndex; + delete node.parent.nodeIndex; + const context = new Context({ include: [['article']] }, axe._tree); + assert.isTrue(isNodeInContext(node, context)); + }); + + it('is false when the node is not included', () => { + const node = queryFixture( + `
` + ); + + delete node.nodeIndex; + delete node.children[0].nodeIndex; + const context = new Context({ include: [['section']] }, axe._tree); + assert.isFalse(isNodeInContext(node, context)); + }); + }); }); From 8cd13d0460f6dd179b30d3590bec49b67dd5629c Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 28 Nov 2022 14:27:49 +0100 Subject: [PATCH 05/14] Fix tests --- lib/core/base/context/normalize-context.js | 5 +- lib/core/public/run-virtual-rule.js | 2 +- lib/core/utils/is-node-in-context.js | 8 +-- test/core/utils/select.js | 68 +++++++++++++--------- 4 files changed, 51 insertions(+), 32 deletions(-) diff --git a/lib/core/base/context/normalize-context.js b/lib/core/base/context/normalize-context.js index 89ca13b163..ac1dc6265d 100644 --- a/lib/core/base/context/normalize-context.js +++ b/lib/core/base/context/normalize-context.js @@ -174,5 +174,8 @@ function assert(bool, str) { // Wrapper to prevent throwing for non-objects & null function objectHasOwn(obj, prop) { - return obj && typeof obj === 'object' && Object.hasOwn(obj, prop); + if (!obj || typeof obj !== 'object') { + return false; + } + return Object.prototype.hasOwnProperty.call(obj, prop); } diff --git a/lib/core/public/run-virtual-rule.js b/lib/core/public/run-virtual-rule.js index 6c85fa2039..189720f198 100644 --- a/lib/core/public/run-virtual-rule.js +++ b/lib/core/public/run-virtual-rule.js @@ -16,7 +16,7 @@ import { * @param {Object} options (optional) Set of options passed into rules or checks * @return {Object} axe results for the rule run */ - export default function runVirtualRule(ruleId, vNode, options = {}) { +export default function runVirtualRule(ruleId, vNode, options = {}) { // TODO: es-modules axe._audit // TODO: es-modules axe._selectorData options.reporter = options.reporter || axe._audit.reporter || 'v1'; diff --git a/lib/core/utils/is-node-in-context.js b/lib/core/utils/is-node-in-context.js index 75ccb7a7f1..cb8dbcb35c 100644 --- a/lib/core/utils/is-node-in-context.js +++ b/lib/core/utils/is-node-in-context.js @@ -7,7 +7,7 @@ import contains from './contains'; * @param {Object} context "Resolved" context object, @see resolveContext * @return {Boolean} [description] */ - export default function isNodeInContext(node, { include = [], exclude = []}) { +export default function isNodeInContext(node, { include = [], exclude = [] }) { const filterInclude = include.filter(candidate => contains(candidate, node)); if (filterInclude.length === 0) { return false; @@ -27,11 +27,11 @@ import contains from './contains'; * @param {Array} collection Array of nodes to test * @return {Node} The deepest node */ - function getDeepest(collection) { - let deepest +function getDeepest(collection) { + let deepest; for (const node of collection) { if (!deepest || !contains(node, deepest)) { - deepest = node + deepest = node; } } return deepest; diff --git a/test/core/utils/select.js b/test/core/utils/select.js index 54866a26cf..c5a6a968ef 100644 --- a/test/core/utils/select.js +++ b/test/core/utils/select.js @@ -13,8 +13,8 @@ describe('axe.utils.select', () => { describe('selector', () => { it('should accept a selector', () => { - fixtureSetup('
') - const context = new Context(document, axe._tree) + fixtureSetup('
'); + const context = new Context(document, axe._tree); const result = axe.utils.select('#monkeys', context); assert.equal(result[0].actualNode, $id('monkeys')); }); @@ -34,10 +34,13 @@ describe('axe.utils.select', () => { fixtureSetup( '
' ); - const context = new Context({ - include: [['#fixture']], - exclude: [['#monkeys']] - }, axe._tree); + const context = new Context( + { + include: [['#fixture']], + exclude: [['#monkeys']] + }, + axe._tree + ); const result = axe.utils.select('.bananas', context); assert.isEmpty(result); }); @@ -52,11 +55,15 @@ describe('axe.utils.select', () => { - `) - const context = new Context({ - include: [['#include1'], ['#include2']], - exclude: [['#exclude1'], ['#exclude2']], - }, axe._tree); + ` + ); + const context = new Context( + { + include: [['#include1'], ['#include2']], + exclude: [['#exclude1'], ['#exclude2']] + }, + axe._tree + ); const result = axe.utils.select('.bananas', context); assert.deepEqual(result, []); }); @@ -75,10 +82,13 @@ describe('axe.utils.select', () => { ` ); - const context = new Context({ - include: [['#include3'], ['#include2'], ['#include1']], - exclude: [['#exclude1'], ['#exclude2']], - }, axe._tree); + const context = new Context( + { + include: [['#include3'], ['#include2'], ['#include1']], + exclude: [['#exclude1'], ['#exclude2']] + }, + axe._tree + ); const result = axe.utils.select('.bananas', context); assert.deepEqual(result[0].actualNode, $id('bananas')); }); @@ -88,10 +98,13 @@ describe('axe.utils.select', () => { fixtureSetup( '
' ); - const context = new Context({ - include: [['#fixture'], ['#monkeys']] - }, axe._tree) - + const context = new Context( + { + include: [['#fixture'], ['#monkeys']] + }, + axe._tree + ); + const result = axe.utils.select('.bananas', context); assert.lengthOf(result, 1); assert.equal(result[0].actualNode, $id('bananas')); @@ -100,12 +113,15 @@ describe('axe.utils.select', () => { it('should not return duplicates on overlapping includes', () => { fixtureSetup( '
' + - '
' + '
' + ); + const context = new Context( + { + include: [['#zero'], ['#one']] + }, + axe._tree ); - const context = new Context({ - include: [['#zero'], ['#one']] - }, axe._tree) - + const result = axe.utils.select('.bananas', context); assert.deepEqual( result.map(n => n.actualNode), @@ -117,9 +133,9 @@ describe('axe.utils.select', () => { it('should return the cached result if one exists', () => { fixtureSetup( '
' + - '
' + '
' ); - + axe._selectCache = [ { selector: '.bananas', From b58beb1ec4ee68025253edf10c61ff20184983cc Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 28 Nov 2022 15:41:58 +0100 Subject: [PATCH 06/14] Update type definition --- axe.d.ts | 52 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/axe.d.ts b/axe.d.ts index b1fc54ce1d..1b492ee301 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -45,23 +45,44 @@ declare namespace axe { | 'embedded' | 'interactive'; + // Selectors within a frame type BaseSelector = string; - type CrossTreeSelector = BaseSelector | BaseSelector[]; - type CrossFrameSelector = CrossTreeSelector[]; + type ShadowDomSelector = BaseSelector[]; + type CrossTreeSelector = BaseSelector | ShadowDomSelector; + type LabelledShadowDomSelector = { fromShadowDom: ShadowDomSelector }; - type ContextObject = { - include?: Node | BaseSelector | Array; - exclude?: Node | BaseSelector | Array; - }; + // Cross-frame selectors + type FramesSelector = Array; + type UnlabelledFrameSelector = CrossTreeSelector[]; + type LabelledFramesSelector = { fromFrames: FramesSelector }; + /** + * @deprecated Use UnlabelledFrameSelector instead + */ + type CrossFrameSelector = UnlabelledFrameSelector; - type SerialContextObject = { - include?: BaseSelector | Array; - exclude?: BaseSelector | Array; - }; + // Context options + type Selector = + | Node + | BaseSelector + | LabelledShadowDomSelector + | LabelledFramesSelector; + type SelectorList = Array | NodeList; + type ContextObject = + | { + include: Selector | SelectorList; + exclude?: Selector | SelectorList; + } + | { + exclude: Selector | SelectorList; + }; + type ElementContext = Selector | SelectorList | ContextObject; - type RunCallback = (error: Error, results: AxeResults) => void; + interface SerialContextObject { + include: UnlabelledFrameSelector[]; + exclude: UnlabelledFrameSelector[]; + } - type ElementContext = Node | NodeList | string | ContextObject; + type RunCallback = (error: Error, results: AxeResults) => void; interface TestEngine { name: string; @@ -255,9 +276,9 @@ declare namespace axe { interface SerialDqElement { source: string; nodeIndexes: number[]; - selector: CrossFrameSelector; + selector: UnlabelledFrameSelector; xpath: string[]; - ancestry: CrossFrameSelector; + ancestry: UnlabelledFrameSelector; } interface PartialRuleResult { id: string; @@ -273,7 +294,7 @@ declare namespace axe { } type PartialResults = Array; interface FrameContext { - frameSelector: CrossTreeSelector; + frameSelector: UnlabelledFrameSelector; frameContext: SerialContextObject; } interface Utils { @@ -282,6 +303,7 @@ declare namespace axe { options?: RunOptions ) => FrameContext[]; shadowSelect: (selector: CrossTreeSelector) => Element | null; + shadowSelectAll: (selector: CrossTreeSelector) => Element[]; } interface EnvironmentData { testEngine: TestEngine; From 5c99df3af34327fb19a7f50437ea070c79db8741 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Tue, 29 Nov 2022 16:24:46 +0100 Subject: [PATCH 07/14] Write docs --- doc/API.md | 76 +++---------- doc/context.md | 290 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 59 deletions(-) create mode 100644 doc/context.md diff --git a/doc/API.md b/doc/API.md index b6c92977fd..6108b79d4a 100644 --- a/doc/API.md +++ b/doc/API.md @@ -321,57 +321,28 @@ By default, `axe.run` will test the entire document. The context object is an op - Example: To limit analysis to the `
` element: `document.getElementById("content")` 1. A NodeList such as returned by `document.querySelectorAll`. 1. A [CSS selector](./developer-guide.md#supported-css-selectors) that selects the portion(s) of the document that must be analyzed. -1. An include-exclude object (see below) +1. An object with `exclude` and/or `include` properties +1. An object with a `fromFrames` property +1. An object with a `fromShadowDom` property -###### Include-Exclude Object - -The include exclude object is a JSON object with two attributes: include and exclude. Either include or exclude is required. If only `exclude` is specified; include will default to the entire `document`. - -- A node, or -- An array of Nodes or an array of arrays of [CSS selectors](./developer-guide.md#supported-css-selectors) - - If the nested array contains a single string, that string is the CSS selector - - If the nested array contains multiple strings - - The last string is the final CSS selector - - All other's are the nested structure of iframes inside the document - -In most cases, the component arrays will contain only one CSS selector. Multiple CSS selectors are only required if you want to include or exclude regions of a page that are inside iframes (or iframes within iframes within iframes). In this case, the first n-1 selectors are selectors that select the iframe(s) and the nth selector, selects the region(s) within the iframe. +Read [context.md](context.md) for details about the context object ###### Context Parameter Examples -1. Include the first item in the `$fixture` NodeList but exclude its first child - -```js -axe.run( - { - include: $fixture[0], - exclude: $fixture[0].firstChild - }, - (err, results) => { - // ... - } -); -``` - -2. Include the element with the ID of `fix` but exclude any `div`s within it +1. Test the `#navBar` and all other `nav` elements and its content ```js -axe.run( - { - include: [['#fix']], - exclude: [['#fix div']] - }, - (err, results) => { - // ... - } -); +axe.run([`#navBar`, `nav`], (err, results) => { + // ... +}); ``` -3. Include the whole document except any structures whose parent contains the class `exclude1` or `exclude2` +2. Test everything except `.ad-banner` elements ```js axe.run( { - exclude: [['.exclude1'], ['.exclude2']] + exclude: '.ad-banner' }, (err, results) => { // ... @@ -379,12 +350,12 @@ axe.run( ); ``` -4. Include the element with the ID of `fix`, within the iframe with id `frame` +3. Test the `form` element inside the `#payment` iframe ```js axe.run( { - include: [['#frame', '#fix']] + fromFrames: ['iframe#payment', 'form'] }, (err, results) => { // ... @@ -392,12 +363,14 @@ axe.run( ); ``` -5. Include the element with the ID of `fix`, within the iframe with id `frame2`, within the iframe with id `frame1` +4. Exclude all `.commentBody` elements in each `.commentsShadowHost` shadow DOM tree ```js axe.run( { - include: [['#frame1', '#frame2', '#fix']] + exclude: { + fromShadowDom: ['.commentsShadowHost', '.commentBody'] + } }, (err, results) => { // ... @@ -405,22 +378,7 @@ axe.run( ); ``` -6. Include the following: - -- The element with the ID of `fix`, within the iframe with id `frame2`, within the iframe with id `frame1` -- The element with id `header` -- All links - -```js -axe.run( - { - include: [['#header'], ['a'], ['#frame1', '#frame2', '#fix']] - }, - (err, results) => { - // ... - } -); -``` +More details on how to use the context object are described in [context.md](context.md). ##### Options Parameter diff --git a/doc/context.md b/doc/context.md new file mode 100644 index 0000000000..5a86a26491 --- /dev/null +++ b/doc/context.md @@ -0,0 +1,290 @@ +# Axe Testing Context + +Axe-core's `context` argument is a powerful tool for controlling precisely which elements are tested and which are ignored. The context lets you do many things, including: + +1. [Test specific elements](#test-specific-elements) +1. [Test DOM nodes](#test-dom-nodes) +1. [Excludes elements from test](#exclude-elements-from-test) +1. [Select from prior tests](#select-from-prior-tests) +1. [Limit frame testing](#limit-frame-testing) +1. [Limit shadow DOM testing](#limit-shadow-dom-testing) +1. [Combine shadow DOM and frame context](#combine-shadow-dom-and-frame-context) +1. [Implicit frame and shadow DOM selection](#implicit-frame-and-shadow-dom-selection) + +## Test Specific Elements + +When passed a CSS selector or array of CSS selectors, axe will test only the elements that match those selectors, along with any content inside those elements: + +```js +// Test every