diff --git a/lib/checks/navigation/heading-order-after.js b/lib/checks/navigation/heading-order-after.js index ce0de44f09..fd7f73da19 100644 --- a/lib/checks/navigation/heading-order-after.js +++ b/lib/checks/navigation/heading-order-after.js @@ -1,15 +1,138 @@ +const joinStr = ' > '; + +/** + * Flatten an ancestry path of an iframe result into a string. + */ +function getFramePath(ancestry, nodePath) { + // remove the last path so we're only left with iframe paths + ancestry = ancestry.slice(0, ancestry.length - 1); + + if (nodePath) { + ancestry = ancestry.concat(nodePath); + } + + return ancestry.join(joinStr); +} + function headingOrderAfter(results) { if (results.length < 2) { return results; } - var prevLevel = results[0].data; + /** + * In order to correctly return heading order results (even for + * headings that may be out of the current context) we need to + * construct an in-order list of all headings on the page, + * including headings from iframes. + * + * To do this we will find all nested headingOrders (i.e. those + * from iframes) and then determine where those results fit into + * the top-level heading order. once we've put all the heading + * orders into their proper place, we can then determine which + * headings are not in the correct order. + **/ + + // start by replacing all array ancestry paths with a flat string + // path + let headingOrder = results[0].data.headingOrder.map(heading => { + return { + ...heading, + ancestry: getFramePath(results[0].node.ancestry, heading.ancestry) + }; + }); + + // find all nested headindOrders + const nestedResults = results.filter(result => { + return result.data && result.data.headingOrder && result.node._fromFrame; + }); + + // update the path of nodes to include the iframe path + nestedResults.forEach(result => { + result.data.headingOrder = result.data.headingOrder.map(heading => { + return { + ...heading, + ancestry: getFramePath(result.node.ancestry, heading.ancestry) + }; + }); + }); + + /** + * Determine where the iframe results fit into the top-level + * heading order + */ + function getFrameIndex(result) { + const path = getFramePath(result.node.ancestry); + const heading = headingOrder.find(heading => { + return heading.ancestry === path; + }); + return headingOrder.indexOf(heading); + } + + /** + * Replace an iframe placeholder with its results + */ + function replaceFrameWithResults(index, result) { + headingOrder.splice(index, 1, ...result.data.headingOrder); + } + + // replace each iframe in the top-level heading order with its + // results. + // since nested iframe results can appear before their parent + // iframe, we will just loop over the nested results and + // piece-meal replace each iframe in the top-level heading order + // with their results until we no longer have results to replace + let replaced = false; + while (nestedResults.length) { + for (let i = 0; i < nestedResults.length; ) { + const nestedResult = nestedResults[i]; + const index = getFrameIndex(nestedResult); + + if (index !== -1) { + replaceFrameWithResults(index, nestedResult); + replaced = true; + + // remove the nested result from the list + nestedResults.splice(i, 1); + } else { + i++; + } + } + + // something went wrong if we can't replace an iframe in + // the top-level results + if (!replaced) { + throw new Error('Unable to find parent iframe of heading-order results'); + } + } + + // replace the ancestry path with information about the result + results.forEach(result => { + const path = result.node.ancestry.join(joinStr); + const heading = headingOrder.find(heading => { + return heading.ancestry === path; + }); + const index = headingOrder.indexOf(heading); + headingOrder.splice(index, 1, { + level: headingOrder[index].level, + result + }); + }); + + // remove any iframes that aren't in context (level == -1) + headingOrder = headingOrder.filter(heading => heading.level > 0); - for (var i = 1; i < results.length; i++) { - if (results[i].result && results[i].data > prevLevel + 1) { - results[i].result = false; + // now make sure all headings are in the correct order + for (let i = 1; i < results.length; i++) { + const result = results[i]; + const heading = headingOrder.find(heading => { + return heading.result === result; + }); + const index = headingOrder.indexOf(heading); + const currLevel = headingOrder[index].level; + const prevLevel = headingOrder[index - 1].level; + if (currLevel - prevLevel > 1) { + result.result = false; } - prevLevel = results[i].data; } return results; diff --git a/lib/checks/navigation/heading-order-evaluate.js b/lib/checks/navigation/heading-order-evaluate.js index 2ece58357a..73d4f05f64 100644 --- a/lib/checks/navigation/heading-order-evaluate.js +++ b/lib/checks/navigation/heading-order-evaluate.js @@ -1,19 +1,57 @@ -function headingOrderEvaluate(node, options, virtualNode) { - const ariaHeadingLevel = virtualNode.attr('aria-level'); - const nodeName = virtualNode.props.nodeName; +import cache from '../../core/base/cache'; +import { querySelectorAllFilter, getAncestry } from '../../core/utils'; +import { isVisible } from '../../commons/dom'; - if (ariaHeadingLevel !== null) { - this.data(parseInt(ariaHeadingLevel, 10)); - return true; - } +function getLevel(vNode) { + const role = vNode.attr('role'); + if (role && role.includes('heading')) { + const ariaHeadingLevel = vNode.attr('aria-level'); + const level = parseInt(ariaHeadingLevel, 10); - const headingLevel = nodeName.toUpperCase().match(/H(\d)/); + // default aria-level for a heading is 2 if it is + // not set or set to an incorrect value + // @see https://www.w3.org/TR/wai-aria-1.1/#heading + if (isNaN(level) || level < 1 || level > 6) { + return 2; + } + return level; + } + + const headingLevel = vNode.props.nodeName.match(/h(\d)/); if (headingLevel) { - this.data(parseInt(headingLevel[1], 10)); + return parseInt(headingLevel[1], 10); + } + + return -1; +} + +function headingOrderEvaluate() { + let headingOrder = cache.get('headingOrder'); + if (headingOrder) { return true; } + // find all headings, even ones that are outside the current + // context. also need to know where iframes are so we can insert + // the results of any in-context iframes into their proper order + // @see https://github.com/dequelabs/axe-core/issues/728 + const selector = 'h1, h2, h3, h4, h5, h6, [role=heading], iframe, frame'; + // TODO: es-modules_tree + const vNodes = querySelectorAllFilter(axe._tree[0], selector, vNode => + isVisible(vNode.actualNode, true) + ); + + headingOrder = vNodes.map(vNode => { + // save the path so we can reconstruct the heading order + return { + ancestry: [getAncestry(vNode.actualNode)], + level: getLevel(vNode) + }; + }); + + this.data({ headingOrder }); + cache.set('headingOrder', vNodes); return true; } diff --git a/test/checks/navigation/heading-order.js b/test/checks/navigation/heading-order.js index 879fc64f2e..488d06ed6d 100644 --- a/test/checks/navigation/heading-order.js +++ b/test/checks/navigation/heading-order.js @@ -8,26 +8,54 @@ describe('heading-order', function() { checkContext.reset(); }); - it('should store the correct header level for aria-level and return true', function() { + it('should store the heading order path and level for [role=heading] elements and return true', function() { var vNode = queryFixture( - '
One

Three

' + '
One
Three
' ); assert.isTrue( axe.testUtils .getCheckEvaluate('heading-order') - .call(checkContext, null, {}, vNode) + .call(checkContext, null, {}, vNode, {}) ); - assert.equal(checkContext._data, 1); + assert.deepEqual(checkContext._data, { + headingOrder: [ + { + ancestry: ['html > body > div:nth-child(1) > div:nth-child(1)'], + level: 1 + }, + { + ancestry: ['html > body > div:nth-child(1) > div:nth-child(2)'], + level: 3 + } + ] + }); }); - it('should store the header level as a number', function() { - var vNode = queryFixture('

One

Three

'); + it('should handle incorrect aria-level values', function() { + var vNode = queryFixture( + '
One
Two
Three
' + ); assert.isTrue( axe.testUtils .getCheckEvaluate('heading-order') - .call(checkContext, null, {}, vNode) + .call(checkContext, null, {}, vNode, {}) ); - assert.isNumber(checkContext._data); + assert.deepEqual(checkContext._data, { + headingOrder: [ + { + ancestry: ['html > body > div:nth-child(1) > div:nth-child(1)'], + level: 2 + }, + { + ancestry: ['html > body > div:nth-child(1) > div:nth-child(2)'], + level: 2 + }, + { + ancestry: ['html > body > div:nth-child(1) > div:nth-child(3)'], + level: 2 + } + ] + }); }); it('should store the correct header level for hn tags and return true', function() { @@ -35,55 +63,464 @@ describe('heading-order', function() { assert.isTrue( axe.testUtils .getCheckEvaluate('heading-order') - .call(checkContext, null, {}, vNode) + .call(checkContext, null, {}, vNode, {}) ); - assert.equal(checkContext._data, 1); + assert.deepEqual(checkContext._data, { + headingOrder: [ + { + ancestry: ['html > body > div:nth-child(1) > h1:nth-child(1)'], + level: 1 + }, + { + ancestry: ['html > body > div:nth-child(1) > h3:nth-child(2)'], + level: 3 + } + ] + }); }); - it('should return true and put nothing in data for non-headers', function() { - var vNode = queryFixture('
One

Three

'); - assert.isTrue( - axe.testUtils - .getCheckEvaluate('heading-order') - .call(checkContext, null, {}, vNode) + it('should store the location of iframes', function() { + var vNode = queryFixture( + '

One

Three

' ); - assert.equal(checkContext._data, null); + axe.testUtils + .getCheckEvaluate('heading-order') + .call(checkContext, null, {}, vNode, { initiator: true }); + assert.deepEqual(checkContext._data, { + headingOrder: [ + { + ancestry: ['html > body > div:nth-child(1) > h1:nth-child(1)'], + level: 1 + }, + { + ancestry: ['html > body > div:nth-child(1) > iframe:nth-child(2)'], + level: -1 + }, + { + ancestry: ['html > body > div:nth-child(1) > h3:nth-child(3)'], + level: 3 + } + ] + }); }); - it('should return false when header level increases by 2', function() { - var results = [ - { data: 1, result: true }, - { data: 3, result: true } - ]; - assert.isFalse(checks['heading-order'].after(results)[1].result); - }); + describe('after', function() { + it('should return false when header level increases by 2', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + }, + { + ancestry: 'path2', + level: 3 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path2'] + }, + result: true + } + ]; + assert.isFalse(checks['heading-order'].after(results)[1].result); + }); - it('should return true when header level decreases by 1', function() { - var results = [ - { data: 2, result: true }, - { data: 1, result: true } - ]; - assert.isTrue(checks['heading-order'].after(results)[1].result); - }); + it('should return true when header level decreases by 1', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 2 + }, + { + ancestry: 'path2', + level: 1 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path2'] + }, + result: true + } + ]; + assert.isTrue(checks['heading-order'].after(results)[1].result); + }); - it('should return true when header level decreases by 2', function() { - var results = [ - { data: 3, result: true }, - { data: 1, result: true } - ]; - assert.isTrue(checks['heading-order'].after(results)[1].result); - }); + it('should return true when header level decreases by 2', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 3 + }, + { + ancestry: 'path2', + level: 1 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path2'] + }, + result: true + } + ]; + assert.isTrue(checks['heading-order'].after(results)[1].result); + }); - it('should return true when there is only one header', function() { - var results = [{ data: 1, result: true }]; - assert.isTrue(checks['heading-order'].after(results)[0].result); - }); + it('should return true when there is only one header', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + } + ]; + assert.isTrue(checks['heading-order'].after(results)[0].result); + }); + + it('should return true when header level increases by 1', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + }, + { + ancestry: 'path2', + level: 2 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path2'] + }, + result: true + } + ]; + assert.isTrue(checks['heading-order'].after(results)[1].result); + }); + + it('should return true if heading levels are correct across iframes', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + }, + { + ancestry: 'iframe', + level: -1 + }, + { + ancestry: 'path3', + level: 3 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + data: { + headingOrder: [ + { + ancestry: 'path2', + level: 2 + } + ] + }, + node: { + _fromFrame: true, + ancestry: ['iframe', 'path2'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path3'] + }, + result: true + } + ]; + var afterResults = checks['heading-order'].after(results); + assert.isTrue(afterResults[1].result); + assert.isTrue(afterResults[2].result); + }); + + it('should return false if heading levels are incorrect across iframes', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + }, + { + ancestry: 'iframe', + level: -1 + }, + { + ancestry: 'path3', + level: 3 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + data: { + headingOrder: [ + { + ancestry: 'path2', + level: 4 + } + ] + }, + node: { + _fromFrame: true, + ancestry: ['iframe', 'path2'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path3'] + }, + result: true + } + ]; + var afterResults = checks['heading-order'].after(results); + assert.isFalse(afterResults[1].result); + assert.isTrue(afterResults[2].result); + }); + + it('should handle nested iframes', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + }, + { + ancestry: 'iframe', + level: -1 + }, + { + ancestry: 'path4', + level: 3 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + data: { + headingOrder: [ + { + ancestry: 'path2', + level: 2 + } + ] + }, + node: { + _fromFrame: true, + ancestry: ['iframe', 'iframe2', 'path2'] + }, + result: true + }, + { + data: { + headingOrder: [ + { + ancestry: 'iframe2', + level: -1 + }, + { + ancestry: 'path3', + level: 3 + } + ] + }, + node: { + _fromFrame: true, + ancestry: ['iframe', 'path3'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path4'] + }, + result: true + } + ]; + var afterResults = checks['heading-order'].after(results); + assert.isTrue(afterResults[1].result); + assert.isTrue(afterResults[2].result); + assert.isTrue(afterResults[3].result); + }); + + it('should skip iframes not in context', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + }, + { + ancestry: 'iframe', + level: -1 + }, + { + ancestry: 'path2', + level: 2 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path2'] + }, + result: true + } + ]; + var afterResults = checks['heading-order'].after(results); + assert.isTrue(afterResults[1].result); + }); - it('should return true when header level increases by 1', function() { - var results = [ - { data: 1, result: true }, - { data: 2, result: true } - ]; - assert.isTrue(checks['heading-order'].after(results)[1].result); + it('should throw error if iframe cannot be replaced', function() { + var results = [ + { + data: { + headingOrder: [ + { + ancestry: 'path1', + level: 1 + }, + { + ancestry: 'iframe', + level: -1 + }, + { + ancestry: 'path3', + level: 3 + } + ] + }, + node: { + _fromFrame: false, + ancestry: ['path1'] + }, + result: true + }, + { + data: { + headingOrder: [ + { + ancestry: 'path2', + level: 2 + } + ] + }, + node: { + _fromFrame: true, + ancestry: ['uknown iframe', 'path2'] + }, + result: true + }, + { + node: { + _fromFrame: false, + ancestry: ['path3'] + }, + result: true + } + ]; + assert.throws(function() { + checks['heading-order'].after(results); + }); + }); }); }); diff --git a/test/integration/full/heading-order/frames/heading.html b/test/integration/full/heading-order/frames/heading.html new file mode 100644 index 0000000000..dd34729885 --- /dev/null +++ b/test/integration/full/heading-order/frames/heading.html @@ -0,0 +1,11 @@ + + + + + + + +

Heading 3

+ + + diff --git a/test/integration/full/heading-order/frames/nested-heading.html b/test/integration/full/heading-order/frames/nested-heading.html new file mode 100644 index 0000000000..5a3614d2ed --- /dev/null +++ b/test/integration/full/heading-order/frames/nested-heading.html @@ -0,0 +1,10 @@ + + + + + + + +

Heading 4

+ + diff --git a/test/integration/full/heading-order/partial-context-with-iframe-excluded.html b/test/integration/full/heading-order/partial-context-with-iframe-excluded.html new file mode 100644 index 0000000000..f2cf2c1c0b --- /dev/null +++ b/test/integration/full/heading-order/partial-context-with-iframe-excluded.html @@ -0,0 +1,38 @@ + + + + frame exclude test + + + + + + + + +
+

Foo

+
+
+

bar

+
+ + +
+ + + + + diff --git a/test/integration/full/heading-order/partial-context-with-iframe-excluded.js b/test/integration/full/heading-order/partial-context-with-iframe-excluded.js new file mode 100644 index 0000000000..e495a9c5cc --- /dev/null +++ b/test/integration/full/heading-order/partial-context-with-iframe-excluded.js @@ -0,0 +1,30 @@ +describe('heading-order-partial-context-with-iframe test', function() { + 'use strict'; + + var results; + before(function(done) { + axe.testUtils.awaitNestedLoad(function() { + axe.run( + { include: [['header'], ['footer']] }, + { runOnly: ['heading-order'] }, + function(err, r) { + assert.isNull(err); + results = r; + done(); + } + ); + }); + }); + + describe('violations', function() { + it('should find one', function() { + assert.lengthOf(results.violations, 1); + }); + }); + + describe('passes', function() { + it('should find 1', function() { + assert.lengthOf(results.passes[0].nodes, 1); + }); + }); +}); diff --git a/test/integration/full/heading-order/partial-context-with-iframe.html b/test/integration/full/heading-order/partial-context-with-iframe.html new file mode 100644 index 0000000000..9ae8f7945b --- /dev/null +++ b/test/integration/full/heading-order/partial-context-with-iframe.html @@ -0,0 +1,38 @@ + + + + frame exclude test + + + + + + + + +
+

Foo

+
+
+

bar

+
+ + +
+ + + + + diff --git a/test/integration/full/heading-order/partial-context-with-iframe.js b/test/integration/full/heading-order/partial-context-with-iframe.js new file mode 100644 index 0000000000..1bcf095fbf --- /dev/null +++ b/test/integration/full/heading-order/partial-context-with-iframe.js @@ -0,0 +1,30 @@ +describe('heading-order-partial-context-with-iframe test', function() { + 'use strict'; + + var results; + before(function(done) { + axe.testUtils.awaitNestedLoad(function() { + axe.run( + { include: [['header'], ['footer'], ['iframe']] }, + { runOnly: ['heading-order'] }, + function(err, r) { + assert.isNull(err); + results = r; + done(); + } + ); + }); + }); + + describe('violations', function() { + it('should find none', function() { + assert.lengthOf(results.violations, 0); + }); + }); + + describe('passes', function() { + it('should find 4', function() { + assert.lengthOf(results.passes[0].nodes, 4); + }); + }); +}); diff --git a/test/integration/full/heading-order/partial-context.html b/test/integration/full/heading-order/partial-context.html new file mode 100644 index 0000000000..4f43abce38 --- /dev/null +++ b/test/integration/full/heading-order/partial-context.html @@ -0,0 +1,36 @@ + + + + frame exclude test + + + + + + + + +
+

Foo

+
+
+

bar

+
+ +
+ + + + diff --git a/test/integration/full/heading-order/partial-context.js b/test/integration/full/heading-order/partial-context.js new file mode 100644 index 0000000000..43c4d85e94 --- /dev/null +++ b/test/integration/full/heading-order/partial-context.js @@ -0,0 +1,28 @@ +describe('heading-order-partial-context test', function() { + 'use strict'; + + var results; + before(function(done) { + axe.run( + { include: [['header'], ['footer']] }, + { runOnly: ['heading-order'] }, + function(err, r) { + assert.isNull(err); + results = r; + done(); + } + ); + }); + + describe('violations', function() { + it('should find none', function() { + assert.lengthOf(results.violations, 0); + }); + }); + + describe('passes', function() { + it('should find 2', function() { + assert.lengthOf(results.passes[0].nodes, 2); + }); + }); +}); diff --git a/test/integration/rules/heading-order/frame.html b/test/integration/rules/heading-order/frame.html deleted file mode 100644 index a28abfdeb7..0000000000 --- a/test/integration/rules/heading-order/frame.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - landmark-unique test - - - -

Header

-

header

- - diff --git a/test/integration/rules/heading-order/heading-order.html b/test/integration/rules/heading-order/heading-order.html index 8cf04943ee..034f17ef23 100644 --- a/test/integration/rules/heading-order/heading-order.html +++ b/test/integration/rules/heading-order/heading-order.html @@ -7,8 +7,3 @@

Header

Header

Ignore
- -

Header

-

Header

- -

Header

diff --git a/test/integration/rules/heading-order/heading-order.json b/test/integration/rules/heading-order/heading-order.json index 7abbd2424c..befdf53952 100644 --- a/test/integration/rules/heading-order/heading-order.json +++ b/test/integration/rules/heading-order/heading-order.json @@ -8,11 +8,6 @@ ["#heading3"], ["#heading4"], ["#heading5"], - ["#heading7"], - ["#heading8"], - ["#heading9"], - ["iframe", "#frame-heading1"], - ["iframe", "#frame-heading2"], - ["#heading10"] + ["#heading7"] ] }