diff --git a/axe.d.ts b/axe.d.ts index b1fc54ce1d..f3e54bd34d 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -45,23 +45,48 @@ declare namespace axe { | 'embedded' | 'interactive'; + // Array of length 2 or greater + type MultiArray = [T, T, ...T[]]; + + // Selectors within a frame type BaseSelector = string; - type CrossTreeSelector = BaseSelector | BaseSelector[]; - type CrossFrameSelector = CrossTreeSelector[]; - type ContextObject = { - include?: Node | BaseSelector | Array; - exclude?: Node | BaseSelector | Array; - }; + type ShadowDomSelector = MultiArray; + type CrossTreeSelector = BaseSelector | ShadowDomSelector; + type LabelledShadowDomSelector = { fromShadowDom: ShadowDomSelector }; - type SerialContextObject = { - include?: BaseSelector | Array; - exclude?: BaseSelector | Array; - }; + // Cross-frame selectors + type FramesSelector = Array; + type UnlabelledFrameSelector = CrossTreeSelector[]; + type LabelledFramesSelector = { fromFrames: MultiArray }; + /** + * @deprecated Use UnlabelledFrameSelector instead + */ + type CrossFrameSelector = UnlabelledFrameSelector; - type RunCallback = (error: Error, results: AxeResults) => void; + // 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; + + 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 +280,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 +298,7 @@ declare namespace axe { } type PartialResults = Array; interface FrameContext { - frameSelector: CrossTreeSelector; + frameSelector: UnlabelledFrameSelector; frameContext: SerialContextObject; } interface Utils { @@ -282,6 +307,7 @@ declare namespace axe { options?: RunOptions ) => FrameContext[]; shadowSelect: (selector: CrossTreeSelector) => Element | null; + shadowSelectAll: (selector: CrossTreeSelector) => Element[]; } interface EnvironmentData { testEngine: TestEngine; diff --git a/doc/API.md b/doc/API.md index b6c92977fd..60b040ba0e 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..bfe7f3c2b3 --- /dev/null +++ b/doc/context.md @@ -0,0 +1,280 @@ +# 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. [Exclude 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
' + ); + 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 = [ { @@ -159,9 +142,8 @@ describe('axe.utils.select', function () { 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'); }); }); 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..cf935ed650 100644 --- a/test/testutils.js +++ b/test/testutils.js @@ -634,3 +634,33 @@ testUtils.shadowQuerySelector = function shadowQuerySelector(axeSelector, doc) { }); return elm; }; + +testUtils.createNestedShadowDom = function createFixtureShadowTree( + fixture, + ...htmlCodes +) { + if (htmlCodes.length <= 1) { + throw new Error( + 'createNestedShadowDom must contain at least two HTML snippets' + ); + } + let htmlCode; + while ((htmlCode = htmlCodes.shift())) { + appendHtml(fixture, htmlCode); + if (htmlCodes.length) { + const query = fixture.querySelectorAll('#shadowHost, .shadowHost'); + fixture = query[query.length - 1]; + fixture = fixture.attachShadow({ mode: 'open' }); + } + } + return fixture.querySelector('#target'); +}; + +function appendHtml(fixture, htmlCode) { + const tmp = document.createElement('div'); + tmp.innerHTML = htmlCode; + // Append to avoid clobbering other shadow trees with innerHTML + for (const child of tmp.children) { + fixture.appendChild(child.cloneNode(true)); + } +} diff --git a/typings/axe-core/axe-core-tests.ts b/typings/axe-core/axe-core-tests.ts index 1ef3d0db60..d323aff4bd 100644 --- a/typings/axe-core/axe-core-tests.ts +++ b/typings/axe-core/axe-core-tests.ts @@ -29,6 +29,43 @@ axe.run( console.log(error || results); } ); +export async function runAsync() { + await axe.run('main'); // Single selector + await axe.run(['main']); // Array of one selector + await axe.run([['main']]); // Selecting in the outer frame + // @ts-expect-error // Shadow DOM selectors must be at least 2 items long + await axe.run([[['main']]]); + await axe.run([[['#app', 'main']]]); // Selecting in the outer frame + + await axe.run(document.querySelector('main')); + await axe.run(document.querySelectorAll('main')); + // axe.run with frameContext context + await axe.run({ fromShadowDom: ['#app', '#main', '#inner'] }); + // @ts-expect-error // Must be two long: + await axe.run({ fromShadowDom: ['#app'] }); + // @ts-expect-error // Must be two long: + await axe.run({ fromFrames: ['#app'] }); + // axe.run with fromFrames context + await axe.run({ + fromFrames: ['#frame', { fromShadowDom: ['#app', '#main'] }] + }); + // Mixed type array + await axe.run([ + 'main', + document.head, + { fromShadowDom: ['#app', '#header', '#search'] }, + { fromFrames: ['#frame', '#main'] } + ]); + // Combined fromFrames & fromContext + await axe.run({ + include: { fromShadowDom: ['#frame', '#main'] }, + exclude: [ + 'footer', + document.head, + { fromFrames: ['#frame', { fromShadowDom: ['#app', '#main'] }] } + ] + }); +} axe.run( { exclude: [$fixture[0]] }, {},