From 0e74359490b75df15b89e993de90d059d9df5a7c Mon Sep 17 00:00:00 2001 From: jkodu Date: Wed, 5 Jun 2019 14:10:57 +0100 Subject: [PATCH 01/53] initial commit, files generated --- .../navigation/identical-links-same-purpose.js | 2 ++ .../navigation/identical-links-same-purpose.json | 12 ++++++++++++ lib/rules/identical-links-same-purpose-matches.js | 2 ++ lib/rules/identical-links-same-purpose.json | 13 +++++++++++++ .../navigation/identical-links-same-purpose.js | 4 ++++ .../identical-links-same-purpose.html | 1 + .../identical-links-same-purpose.json | 6 ++++++ .../identical-links-same-purpose-matches.js | 4 ++++ 8 files changed, 44 insertions(+) create mode 100644 lib/checks/navigation/identical-links-same-purpose.js create mode 100644 lib/checks/navigation/identical-links-same-purpose.json create mode 100644 lib/rules/identical-links-same-purpose-matches.js create mode 100644 lib/rules/identical-links-same-purpose.json create mode 100644 test/checks/navigation/identical-links-same-purpose.js create mode 100644 test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html create mode 100644 test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json create mode 100644 test/rule-matches/identical-links-same-purpose-matches.js diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js new file mode 100644 index 0000000000..e321dae9e5 --- /dev/null +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -0,0 +1,2 @@ +// TODO: Logic for check +return true; diff --git a/lib/checks/navigation/identical-links-same-purpose.json b/lib/checks/navigation/identical-links-same-purpose.json new file mode 100644 index 0000000000..b5704683ea --- /dev/null +++ b/lib/checks/navigation/identical-links-same-purpose.json @@ -0,0 +1,12 @@ +{ + "id": "identical-links-same-purpose", + "evaluate": "identical-links-same-purpose.js", + "metadata": { + "impact": "", + "messages": { + "pass": "", + "fail": "", + "incomplete": "" + } + } +} diff --git a/lib/rules/identical-links-same-purpose-matches.js b/lib/rules/identical-links-same-purpose-matches.js new file mode 100644 index 0000000000..e13fe0ed87 --- /dev/null +++ b/lib/rules/identical-links-same-purpose-matches.js @@ -0,0 +1,2 @@ +// TODO: Filter node(s) +return node; diff --git a/lib/rules/identical-links-same-purpose.json b/lib/rules/identical-links-same-purpose.json new file mode 100644 index 0000000000..7e456513f1 --- /dev/null +++ b/lib/rules/identical-links-same-purpose.json @@ -0,0 +1,13 @@ +{ + "id": "identical-links-same-purpose", + "matches": "identical-links-same-purpose-matches.js", + "tags": [], + "metadata": { + "description": "Ensure", + "help": "" + }, + "preload": false, + "all": ["identical-links-same-purpose"], + "any": [], + "none": [] +} diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js new file mode 100644 index 0000000000..44a0d8b779 --- /dev/null +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -0,0 +1,4 @@ +describe('identical-links-same-purpose tests', function() { + 'use strict'; + // TODO: Write tests +}); diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html new file mode 100644 index 0000000000..b2425fa95c --- /dev/null +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html @@ -0,0 +1 @@ + diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json new file mode 100644 index 0000000000..e39f594154 --- /dev/null +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json @@ -0,0 +1,6 @@ +{ + "description": "identical-links-same-purpose tests", + "rule": "identical-links-same-purpose", + "violations": [], + "passes": [] +} diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js new file mode 100644 index 0000000000..d2926328c2 --- /dev/null +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -0,0 +1,4 @@ +describe('identical-links-same-purpose-matches', function() { + 'use strict'; + // TODO: Write tests +}); From 56a79a60404bd59d164dd52fbfb7b075771a532a Mon Sep 17 00:00:00 2001 From: jkodu Date: Tue, 18 Jun 2019 11:24:44 +0100 Subject: [PATCH 02/53] add tests for rule matches --- .../identical-links-same-purpose-after.js | 1 + .../identical-links-same-purpose.js | 5 +- .../identical-links-same-purpose.json | 3 +- .../identical-links-same-purpose-matches.js | 8 +- lib/rules/identical-links-same-purpose.json | 6 +- .../identical-links-same-purpose-matches.js | 76 ++++++++++++++++++- 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 lib/checks/navigation/identical-links-same-purpose-after.js diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js new file mode 100644 index 0000000000..c8f8fcceae --- /dev/null +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -0,0 +1 @@ +return results; diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index e321dae9e5..317c7c039f 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -1,2 +1,5 @@ -// TODO: Logic for check +/** + * Note: + * `identical-links-same-purpose-after`, helps reconcile the results + */ return true; diff --git a/lib/checks/navigation/identical-links-same-purpose.json b/lib/checks/navigation/identical-links-same-purpose.json index b5704683ea..ff0bf26c43 100644 --- a/lib/checks/navigation/identical-links-same-purpose.json +++ b/lib/checks/navigation/identical-links-same-purpose.json @@ -1,8 +1,9 @@ { "id": "identical-links-same-purpose", "evaluate": "identical-links-same-purpose.js", + "after": "identical-links-same-purpose-after.js", "metadata": { - "impact": "", + "impact": "minor", "messages": { "pass": "", "fail": "", diff --git a/lib/rules/identical-links-same-purpose-matches.js b/lib/rules/identical-links-same-purpose-matches.js index e13fe0ed87..782df440fd 100644 --- a/lib/rules/identical-links-same-purpose-matches.js +++ b/lib/rules/identical-links-same-purpose-matches.js @@ -1,2 +1,6 @@ -// TODO: Filter node(s) -return node; +const { aria, text } = axe.commons; + +const role = aria.getRole(node); +const accText = text.accessibleText(node); + +return role === `link` && !!accText; diff --git a/lib/rules/identical-links-same-purpose.json b/lib/rules/identical-links-same-purpose.json index 7e456513f1..51ee8c4927 100644 --- a/lib/rules/identical-links-same-purpose.json +++ b/lib/rules/identical-links-same-purpose.json @@ -1,10 +1,10 @@ { "id": "identical-links-same-purpose", "matches": "identical-links-same-purpose-matches.js", - "tags": [], + "tags": ["wcag2aaa", "wcag249"], "metadata": { - "description": "Ensure", - "help": "" + "description": "Ensures that links with identical accessible names resolve to the same resource", + "help": "Links with resolve to same or equivalent resources" }, "preload": false, "all": ["identical-links-same-purpose"], diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index d2926328c2..d133aeb3cc 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -1,4 +1,76 @@ -describe('identical-links-same-purpose-matches', function() { +describe('identical-links-same-purpose-matches tests', function() { 'use strict'; - // TODO: Write tests + + var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; + var rule = axe._audit.rules.find(function(rule) { + return rule.id === 'identical-links-same-purpose'; + }); + + afterEach(function() { + fixture.innerHTML = ''; + axe._tree = undefined; + }); + + it('returns false when
element has no implicit role', function() { + var vNode = queryFixture('
Some content
'); + var actual = rule.matches(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns false when element has no role !== link', function() { + var vNode = queryFixture(''); + var actual = rule.matches(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns false when element no href attribute', function() { + var vNode = queryFixture('Go to google.com'); + var actual = rule.matches(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns true when element no href attribute but has role === link', function() { + var vNode = queryFixture('Go to google.com'); + var actual = rule.matches(vNode.actualNode); + assert.isTrue(actual); + }); + + it('returns true when element has href attribute (implicit role === link)', function() { + var vNode = queryFixture( + 'Go to google.com' + ); + var actual = rule.matches(vNode.actualNode); + assert.isTrue(actual); + }); + + it('returns true when element has href attribute but no accessible name', function() { + var vNode = queryFixture(''); + var actual = rule.matches(vNode.actualNode); + assert.isTrue(actual); + }); + + it('returns false when element has no href attribute', function() { + var vNode = queryFixture( + 'MDN' + ); + var actual = rule.matches(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns false when element has href attribute but no accessible name', function() { + var vNode = queryFixture( + '' + ); + var actual = rule.matches(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns true when element has href attribute and an accessible name', function() { + var vNode = queryFixture( + '' + ); + var actual = rule.matches(vNode.actualNode); + assert.isTrue(actual); + }); }); From 7a9f5e53ed9c20d3fcd3d08542efe4e79322a4bd Mon Sep 17 00:00:00 2001 From: jkodu Date: Thu, 20 Jun 2019 20:50:40 +0100 Subject: [PATCH 03/53] reconcile results in after fn --- doc/rule-descriptions.md | 1 + .../identical-links-same-purpose-after.js | 60 ++++++++++++ .../identical-links-same-purpose.js | 41 +++++++- .../identical-links-same-purpose.js | 95 ++++++++++++++++++- .../identical-links-same-purpose.html | 1 - .../identical-links-same-purpose.json | 6 -- .../identical-links-same-purpose-matches.js | 4 +- 7 files changed, 197 insertions(+), 11 deletions(-) delete mode 100644 test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html delete mode 100644 test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 527f42c8c8..707c3e4b73 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -39,6 +39,7 @@ | html-has-lang | Ensures every HTML document has a lang attribute | Serious | cat.language, wcag2a, wcag311 | true | | html-lang-valid | Ensures the lang attribute of the <html> element has a valid value | Serious | cat.language, wcag2a, wcag311 | true | | html-xml-lang-mismatch | Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page | Moderate | cat.language, wcag2a, wcag311 | true | +| identical-links-same-purpose | Ensures that links with identical accessible names resolve to the same resource | Minor | wcag2aaa, wcag249 | true | | image-alt | Ensures <img> elements have alternate text or a role of none or presentation | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | | image-redundant-alt | Ensure button and link text is not repeated as image alternative | Minor | cat.text-alternatives, best-practice | true | | input-image-alt | Ensures <input type="image"> elements have alternate text | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index c8f8fcceae..d5aae05b09 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -1 +1,61 @@ +/** + * Skip unless there are more than a single result + */ +if (results.length < 2) { + return results; +} + +/** + * for each result + * - get other results with matching accessible name + * - check if same purpose is served + * - if not change `result` to `undefined` + */ +for (let i = 0; i < results.length; i++) { + const { accessibleText, linkResource } = results[i].data; + const resourceLocation = linkResource.split('/').pop(); + + const matchingIndices = getIndicesOfLinksWithMatchingAccessibleText( + accessibleText, + i + ); + const identicalLinks = matchingIndices.map(index => results[index]); + + if (!hasSamePurpose(resourceLocation, identicalLinks)) { + results[i].result = undefined; + } +} + return results; + +/** + * Get list of items from results which match a prescribed accessible name + * @param {String} expectedAccessibleText accessible name to be matched + * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration + * @returns {Array} + */ +function getIndicesOfLinksWithMatchingAccessibleText( + expectedAccessibleText, + excludeIndex +) { + return results + .map(({ data: { accessibleText } }, index) => { + if (index !== excludeIndex && accessibleText === expectedAccessibleText) { + return index; + } + }) + .filter(value => typeof value !== 'undefined'); +} + +/** + * Check if a given set of links have same resource + * @param {String} expectedResource resource string + * @param {Array} identicalLinks results where `data.accessibleText` were identical + * @returns {Boolean} + */ +function hasSamePurpose(expectedResource, identicalLinks) { + return identicalLinks.every(({ data: { linkResource } }) => { + const resource = linkResource.split('/').pop(); + return expectedResource.includes(resource); + }); +} diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index 317c7c039f..e06ddf978f 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -1,5 +1,44 @@ /** * Note: - * `identical-links-same-purpose-after`, helps reconcile the results + * `identical-links-same-purpose-after` fn, helps reconcile the results & alter the CheckResult accordingly */ +const { text } = axe.commons; +const accessibleText = text.accessibleText(node).toLowerCase(); +const linkResource = getLinkResource(node); + +if (!linkResource) { + return undefined; +} + +/** + * Set `data` for use in `after` fn + */ +this.data({ + accessibleText, + linkResource +}); + return true; + +/** + * Get resource that a element points to (eg: href or location redirects) + * @param {HTMLElement} el Element + * @returns {String|null} + */ +function getLinkResource(el) { + if (!el.hasAttributes()) { + return null; + } + if (el.hasAttribute('href')) { + return el.getAttribute('href').toLowerCase(); + } + + const resourceAttr = Array.from(el.attributes).find(({ value }) => + /location/.test(value) + ); + if (resourceAttr) { + return resourceAttr.value.toLowerCase(); + } + + return null; +} diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js index 44a0d8b779..9bcf7b3c7a 100644 --- a/test/checks/navigation/identical-links-same-purpose.js +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -1,4 +1,97 @@ describe('identical-links-same-purpose tests', function() { 'use strict'; - // TODO: Write tests + + var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; + var check = checks['identical-links-same-purpose']; + var checkContext = axe.testUtils.MockCheckContext(); + + afterEach(function() { + fixture.innerHTML = ''; + checkContext.reset(); + }); + + it('returns undefined when element does not have a resource (empty href)', function() { + var vNode = queryFixture('Go to google.com'); + var actual = check.evaluate.call(checkContext, vNode.actualNode); + assert.isUndefined(actual); + assert.isNull(checkContext._data); + }); + + it('returns undefined when element does not have a resource (onclick does not change location)', function() { + var vNode = queryFixture( + 'Link text' + ); + var actual = check.evaluate.call(checkContext, vNode.actualNode); + assert.isUndefined(actual); + assert.isNull(checkContext._data); + }); + + it('returns true when element has location resource', function() { + var vNode = queryFixture( + 'Follow us' + ); + var actual = check.evaluate.call(checkContext, vNode.actualNode); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['accessibleText', 'linkResource']); + }); + + it('returns true when element has location resource', function() { + var vNode = queryFixture( + 'Link text' + ); + var actual = check.evaluate.call(checkContext, vNode.actualNode); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['accessibleText', 'linkResource']); + }); + + describe('after', function() { + it('sets results of check result to `undefined` if links do not serve identical purpose', function() { + var checkResults = [ + { + data: { + accessibleText: 'follow us', + linkResource: 'http://facebook.com' + }, + result: true + }, + { + data: { + accessibleText: 'follow us', + linkResource: 'http://instagram.com' + }, + result: true + } + ]; + var results = check.after(checkResults); + + assert.lengthOf(results, 2); + assert.isUndefined(results[0].result); + assert.isUndefined(results[1].result); + }); + + it('sets results of check result to `true` if links serve identical purpose', function() { + var checkResults = [ + { + data: { + accessibleText: 'follow us', + linkResource: 'http://instagram.com/axe' + }, + result: true + }, + { + data: { + accessibleText: 'follow us', + linkResource: 'http://instagram.com/axe' + }, + result: true + } + ]; + var results = check.after(checkResults); + + assert.lengthOf(results, 2); + assert.isTrue(results[0].result); + assert.isTrue(results[1].result); + }); + }); }); diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html deleted file mode 100644 index b2425fa95c..0000000000 --- a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json deleted file mode 100644 index e39f594154..0000000000 --- a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "description": "identical-links-same-purpose tests", - "rule": "identical-links-same-purpose", - "violations": [], - "passes": [] -} diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index d133aeb3cc..aa8804377e 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -44,10 +44,10 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isTrue(actual); }); - it('returns true when element has href attribute but no accessible name', function() { + it('returns false when element has href attribute but no accessible name', function() { var vNode = queryFixture(''); var actual = rule.matches(vNode.actualNode); - assert.isTrue(actual); + assert.isFalse(actual); }); it('returns false when element has no href attribute', function() { From b4d85a854b837bc1798d465344e2849465c8ff4b Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 21 Jun 2019 13:26:05 +0100 Subject: [PATCH 04/53] add integration tests --- .../incomplete.html | 36 +++++++++++++ .../incomplete.js | 26 +++++++++ .../identical-links-same-purpose/passes.html | 53 +++++++++++++++++++ .../identical-links-same-purpose/passes.js | 24 +++++++++ .../identical-links-same-purpose-matches.js | 4 +- 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 test/integration/full/identical-links-same-purpose/incomplete.html create mode 100644 test/integration/full/identical-links-same-purpose/incomplete.js create mode 100644 test/integration/full/identical-links-same-purpose/passes.html create mode 100644 test/integration/full/identical-links-same-purpose/passes.js diff --git a/test/integration/full/identical-links-same-purpose/incomplete.html b/test/integration/full/identical-links-same-purpose/incomplete.html new file mode 100644 index 0000000000..c1dc351a15 --- /dev/null +++ b/test/integration/full/identical-links-same-purpose/incomplete.html @@ -0,0 +1,36 @@ + + + + identical-links-same-purpose test + + + + + + + + + + + + Contact + Contact + + Follow us + Follow us + + + + + + diff --git a/test/integration/full/identical-links-same-purpose/incomplete.js b/test/integration/full/identical-links-same-purpose/incomplete.js new file mode 100644 index 0000000000..75a7b67426 --- /dev/null +++ b/test/integration/full/identical-links-same-purpose/incomplete.js @@ -0,0 +1,26 @@ +describe('identical-links-same-purpose incomplete test', function() { + 'use strict'; + + var runConfig = { + runOnly: { + type: 'rule', + values: ['identical-links-same-purpose'] + } + }; + + it('returns incomplete results and no passes', function(done) { + axe.run(runConfig, function(err, res) { + assert.isNull(err); + assert.isDefined(res); + + assert.lengthOf(res.passes, 0); + assert.lengthOf(res.incomplete, 1); + assert.lengthOf(res.incomplete[0].nodes, 4); + assert.equal( + res.incomplete[0].nodes[0].html, + 'Contact' + ); + done(); + }); + }); +}); diff --git a/test/integration/full/identical-links-same-purpose/passes.html b/test/integration/full/identical-links-same-purpose/passes.html new file mode 100644 index 0000000000..3c8238e310 --- /dev/null +++ b/test/integration/full/identical-links-same-purpose/passes.html @@ -0,0 +1,53 @@ + + + + identical-links-same-purpose test + + + + + + + + + + + + + Contact + Contact + + Contact + Contact + + + Link text + + + + Link text + + + + + + + diff --git a/test/integration/full/identical-links-same-purpose/passes.js b/test/integration/full/identical-links-same-purpose/passes.js new file mode 100644 index 0000000000..489b11d9df --- /dev/null +++ b/test/integration/full/identical-links-same-purpose/passes.js @@ -0,0 +1,24 @@ +describe('identical-links-same-purpose passes test', function() { + 'use strict'; + + var runConfig = { + runOnly: { + type: 'rule', + values: ['identical-links-same-purpose'] + } + }; + + it('returns incomplete results and no passes', function(done) { + axe.run(runConfig, function(err, res) { + assert.isNull(err); + assert.isDefined(res); + + assert.lengthOf(res.passes, 1); + assert.lengthOf(res.incomplete, 0); + assert.lengthOf(res.passes[0].nodes, 6); + assert.deepEqual(res.passes[0].nodes[5].target, ['span:nth-child(6)']); + + done(); + }); + }); +}); diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index aa8804377e..1657abf36f 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -66,9 +66,9 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isFalse(actual); }); - it('returns true when element has href attribute and an accessible name', function() { + it('returns true when element has href attribute and an accessible name', function() { var vNode = queryFixture( - '' + '' ); var actual = rule.matches(vNode.actualNode); assert.isTrue(actual); From 41e0cbf69c80f62064d85d2adf7f1ae2573d394b Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 21 Jun 2019 13:28:58 +0100 Subject: [PATCH 05/53] update tags and messages --- lib/checks/navigation/identical-links-same-purpose.json | 5 ++--- lib/rules/identical-links-same-purpose.json | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/checks/navigation/identical-links-same-purpose.json b/lib/checks/navigation/identical-links-same-purpose.json index ff0bf26c43..26716f3ccd 100644 --- a/lib/checks/navigation/identical-links-same-purpose.json +++ b/lib/checks/navigation/identical-links-same-purpose.json @@ -5,9 +5,8 @@ "metadata": { "impact": "minor", "messages": { - "pass": "", - "fail": "", - "incomplete": "" + "pass": "Identical links (if any) resolve to same purpose", + "incomplete": "Fix purpose of links with same accessible name" } } } diff --git a/lib/rules/identical-links-same-purpose.json b/lib/rules/identical-links-same-purpose.json index 51ee8c4927..f23bc8bbbd 100644 --- a/lib/rules/identical-links-same-purpose.json +++ b/lib/rules/identical-links-same-purpose.json @@ -1,12 +1,11 @@ { "id": "identical-links-same-purpose", "matches": "identical-links-same-purpose-matches.js", - "tags": ["wcag2aaa", "wcag249"], + "tags": ["wcag2aaa", "wcag249", "experimental"], "metadata": { "description": "Ensures that links with identical accessible names resolve to the same resource", "help": "Links with resolve to same or equivalent resources" }, - "preload": false, "all": ["identical-links-same-purpose"], "any": [], "none": [] From 54a58e1367d236b36614095c16ed890f660e0b3f Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 21 Jun 2019 13:29:59 +0100 Subject: [PATCH 06/53] update rule desc --- doc/rule-descriptions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 707c3e4b73..925605ea2f 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -39,7 +39,7 @@ | html-has-lang | Ensures every HTML document has a lang attribute | Serious | cat.language, wcag2a, wcag311 | true | | html-lang-valid | Ensures the lang attribute of the <html> element has a valid value | Serious | cat.language, wcag2a, wcag311 | true | | html-xml-lang-mismatch | Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page | Moderate | cat.language, wcag2a, wcag311 | true | -| identical-links-same-purpose | Ensures that links with identical accessible names resolve to the same resource | Minor | wcag2aaa, wcag249 | true | +| identical-links-same-purpose | Ensures that links with identical accessible names resolve to the same resource | Minor | wcag2aaa, wcag249, experimental | true | | image-alt | Ensures <img> elements have alternate text or a role of none or presentation | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | | image-redundant-alt | Ensure button and link text is not repeated as image alternative | Minor | cat.text-alternatives, best-practice | true | | input-image-alt | Ensures <input type="image"> elements have alternate text | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | From b6313b97eba80c4c53234a597c634205936c0752 Mon Sep 17 00:00:00 2001 From: jkodu Date: Mon, 24 Jun 2019 09:48:47 +0100 Subject: [PATCH 07/53] some updates based on review --- .../identical-links-same-purpose-after.js | 25 ++++++------------- .../identical-links-same-purpose.js | 8 +++--- lib/rules/identical-links-same-purpose.json | 1 + 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index d5aae05b09..6d3466e08f 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -15,12 +15,7 @@ for (let i = 0; i < results.length; i++) { const { accessibleText, linkResource } = results[i].data; const resourceLocation = linkResource.split('/').pop(); - const matchingIndices = getIndicesOfLinksWithMatchingAccessibleText( - accessibleText, - i - ); - const identicalLinks = matchingIndices.map(index => results[index]); - + const identicalLinks = getIdenticalLinks(accessibleText, i); if (!hasSamePurpose(resourceLocation, identicalLinks)) { results[i].result = undefined; } @@ -32,19 +27,13 @@ return results; * Get list of items from results which match a prescribed accessible name * @param {String} expectedAccessibleText accessible name to be matched * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration - * @returns {Array} + * @returns {Array} */ -function getIndicesOfLinksWithMatchingAccessibleText( - expectedAccessibleText, - excludeIndex -) { - return results - .map(({ data: { accessibleText } }, index) => { - if (index !== excludeIndex && accessibleText === expectedAccessibleText) { - return index; - } - }) - .filter(value => typeof value !== 'undefined'); +function getIdenticalLinks(expectedAccessibleText, excludeIndex) { + return results.filter( + ({ data: { accessibleText } }, index) => + index !== excludeIndex && accessibleText === expectedAccessibleText + ); } /** diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index e06ddf978f..d424c93ea9 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -26,16 +26,14 @@ return true; * @returns {String|null} */ function getLinkResource(el) { - if (!el.hasAttributes()) { - return null; - } if (el.hasAttribute('href')) { return el.getAttribute('href').toLowerCase(); } - const resourceAttr = Array.from(el.attributes).find(({ value }) => - /location/.test(value) + const resourceAttr = Array.from(axe.utils.getNodeAttributes(node)).find( + ({ value }) => /location/.test(value) ); + if (resourceAttr) { return resourceAttr.value.toLowerCase(); } diff --git a/lib/rules/identical-links-same-purpose.json b/lib/rules/identical-links-same-purpose.json index f23bc8bbbd..5e48d560ae 100644 --- a/lib/rules/identical-links-same-purpose.json +++ b/lib/rules/identical-links-same-purpose.json @@ -1,5 +1,6 @@ { "id": "identical-links-same-purpose", + "selector": "a, area, [role=link]", "matches": "identical-links-same-purpose-matches.js", "tags": ["wcag2aaa", "wcag249", "experimental"], "metadata": { From 2ecfbb2810feaf24e3d5fbaea23f6fa2f6d47a52 Mon Sep 17 00:00:00 2001 From: jkodu Date: Tue, 2 Jul 2019 14:24:04 +0100 Subject: [PATCH 08/53] update --- .../identical-links-same-purpose-after.js | 16 +++++----- .../identical-links-same-purpose.js | 14 ++++++--- .../identical-links-same-purpose-matches.js | 2 +- .../identical-links-same-purpose.js | 29 ++++++++++++++++--- .../identical-links-same-purpose-matches.js | 18 ++++++------ 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index 6d3466e08f..f027e827b3 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -11,13 +11,12 @@ if (results.length < 2) { * - check if same purpose is served * - if not change `result` to `undefined` */ -for (let i = 0; i < results.length; i++) { - const { accessibleText, linkResource } = results[i].data; - const resourceLocation = linkResource.split('/').pop(); +for (let index = 0; index < results.length; index++) { + const { accessibleText, linkResource } = results[index].data; + const identicalLinks = getIdenticalLinks(accessibleText, index); - const identicalLinks = getIdenticalLinks(accessibleText, i); - if (!hasSamePurpose(resourceLocation, identicalLinks)) { - results[i].result = undefined; + if (!hasSamePurpose(linkResource, identicalLinks)) { + results[index].result = undefined; } } @@ -44,7 +43,8 @@ function getIdenticalLinks(expectedAccessibleText, excludeIndex) { */ function hasSamePurpose(expectedResource, identicalLinks) { return identicalLinks.every(({ data: { linkResource } }) => { - const resource = linkResource.split('/').pop(); - return expectedResource.includes(resource); + const expected = expectedResource.replace(/\s/g, '').toLowerCase(); + const actual = linkResource.replace(/\s/g, '').toLowerCase(); + return expected === actual; }); } diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index d424c93ea9..b333e9973c 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -3,7 +3,7 @@ * `identical-links-same-purpose-after` fn, helps reconcile the results & alter the CheckResult accordingly */ const { text } = axe.commons; -const accessibleText = text.accessibleText(node).toLowerCase(); +const accessibleText = text.accessibleTextVirtual(virtualNode); const linkResource = getLinkResource(node); if (!linkResource) { @@ -27,15 +27,21 @@ return true; */ function getLinkResource(el) { if (el.hasAttribute('href')) { - return el.getAttribute('href').toLowerCase(); + return el.getAttribute('href'); } const resourceAttr = Array.from(axe.utils.getNodeAttributes(node)).find( - ({ value }) => /location/.test(value) + ({ value }) => { + /** + * get any attribute which has `location.` or `location=` in its value + */ + const attrValue = value.replace(/\s/g, ''); + return /^location[\.\=]/.test(attrValue); + } ); if (resourceAttr) { - return resourceAttr.value.toLowerCase(); + return resourceAttr.value; } return null; diff --git a/lib/rules/identical-links-same-purpose-matches.js b/lib/rules/identical-links-same-purpose-matches.js index 782df440fd..d0897dcade 100644 --- a/lib/rules/identical-links-same-purpose-matches.js +++ b/lib/rules/identical-links-same-purpose-matches.js @@ -1,6 +1,6 @@ const { aria, text } = axe.commons; const role = aria.getRole(node); -const accText = text.accessibleText(node); +const accText = text.accessibleTextVirtual(virtualNode); return role === `link` && !!accText; diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js index 9bcf7b3c7a..68ac311191 100644 --- a/test/checks/navigation/identical-links-same-purpose.js +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -5,6 +5,7 @@ describe('identical-links-same-purpose tests', function() { var queryFixture = axe.testUtils.queryFixture; var check = checks['identical-links-same-purpose']; var checkContext = axe.testUtils.MockCheckContext(); + var options = {}; afterEach(function() { fixture.innerHTML = ''; @@ -13,7 +14,12 @@ describe('identical-links-same-purpose tests', function() { it('returns undefined when element does not have a resource (empty href)', function() { var vNode = queryFixture('Go to google.com'); - var actual = check.evaluate.call(checkContext, vNode.actualNode); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); assert.isUndefined(actual); assert.isNull(checkContext._data); }); @@ -22,7 +28,12 @@ describe('identical-links-same-purpose tests', function() { var vNode = queryFixture( 'Link text' ); - var actual = check.evaluate.call(checkContext, vNode.actualNode); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); assert.isUndefined(actual); assert.isNull(checkContext._data); }); @@ -31,7 +42,12 @@ describe('identical-links-same-purpose tests', function() { var vNode = queryFixture( 'Follow us' ); - var actual = check.evaluate.call(checkContext, vNode.actualNode); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); assert.isTrue(actual); assert.hasAllKeys(checkContext._data, ['accessibleText', 'linkResource']); }); @@ -40,7 +56,12 @@ describe('identical-links-same-purpose tests', function() { var vNode = queryFixture( 'Link text' ); - var actual = check.evaluate.call(checkContext, vNode.actualNode); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); assert.isTrue(actual); assert.hasAllKeys(checkContext._data, ['accessibleText', 'linkResource']); }); diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index 1657abf36f..515d660277 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -14,25 +14,25 @@ describe('identical-links-same-purpose-matches tests', function() { it('returns false when
element has no implicit role', function() { var vNode = queryFixture('
Some content
'); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); it('returns false when element has no role !== link', function() { var vNode = queryFixture(''); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); it('returns false when element no href attribute', function() { var vNode = queryFixture('Go to google.com'); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); it('returns true when element no href attribute but has role === link', function() { var vNode = queryFixture('Go to google.com'); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isTrue(actual); }); @@ -40,13 +40,13 @@ describe('identical-links-same-purpose-matches tests', function() { var vNode = queryFixture( 'Go to google.com' ); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isTrue(actual); }); it('returns false when element has href attribute but no accessible name', function() { var vNode = queryFixture(''); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); @@ -54,7 +54,7 @@ describe('identical-links-same-purpose-matches tests', function() { var vNode = queryFixture( 'MDN' ); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); @@ -62,7 +62,7 @@ describe('identical-links-same-purpose-matches tests', function() { var vNode = queryFixture( '' ); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); @@ -70,7 +70,7 @@ describe('identical-links-same-purpose-matches tests', function() { var vNode = queryFixture( '' ); - var actual = rule.matches(vNode.actualNode); + var actual = rule.matches(vNode.actualNode, vNode); assert.isTrue(actual); }); }); From f365c136dc63e8b6d4129fd70dad7b686fa72d32 Mon Sep 17 00:00:00 2001 From: jkodu Date: Tue, 2 Jul 2019 14:40:46 +0100 Subject: [PATCH 09/53] update test --- .../incomplete.html | 5 ++++- .../identical-links-same-purpose/passes.html | 18 ++++++++++-------- .../identical-links-same-purpose/passes.js | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/test/integration/full/identical-links-same-purpose/incomplete.html b/test/integration/full/identical-links-same-purpose/incomplete.html index c1dc351a15..4077c0a81c 100644 --- a/test/integration/full/identical-links-same-purpose/incomplete.html +++ b/test/integration/full/identical-links-same-purpose/incomplete.html @@ -23,9 +23,12 @@ + + Contact - Contact + Contact + Follow us Follow us diff --git a/test/integration/full/identical-links-same-purpose/passes.html b/test/integration/full/identical-links-same-purpose/passes.html index 3c8238e310..8ee8c922fd 100644 --- a/test/integration/full/identical-links-same-purpose/passes.html +++ b/test/integration/full/identical-links-same-purpose/passes.html @@ -22,28 +22,30 @@ - +
+ Contact Contact - Contact - Contact + + Contact Us + Contact Us + - Link text + Visit Netherlands - - Link text + Visit Netherlands diff --git a/test/integration/full/identical-links-same-purpose/passes.js b/test/integration/full/identical-links-same-purpose/passes.js index 489b11d9df..556652f43a 100644 --- a/test/integration/full/identical-links-same-purpose/passes.js +++ b/test/integration/full/identical-links-same-purpose/passes.js @@ -15,8 +15,8 @@ describe('identical-links-same-purpose passes test', function() { assert.lengthOf(res.passes, 1); assert.lengthOf(res.incomplete, 0); - assert.lengthOf(res.passes[0].nodes, 6); - assert.deepEqual(res.passes[0].nodes[5].target, ['span:nth-child(6)']); + assert.isAbove(res.passes[0].nodes.length, 6); + assert.deepEqual(res.passes[0].nodes[8].target, ['span:nth-child(7)']); done(); }); From a580de9b196559dcf0cd72cc73a520156aa1fa50 Mon Sep 17 00:00:00 2001 From: jkodu Date: Tue, 13 Aug 2019 15:40:31 +0100 Subject: [PATCH 10/53] fix: update rule implementation --- doc/rule-descriptions.md | 2 +- .../identical-links-same-purpose-after.js | 136 +++++++++++-- .../identical-links-same-purpose.js | 50 ++--- .../identical-links-same-purpose.json | 4 +- .../identical-links-same-purpose-matches.js | 14 +- lib/rules/identical-links-same-purpose.json | 9 +- .../identical-links-same-purpose-after.js | 42 ++++ .../identical-links-same-purpose.js | 142 +++++++------ .../incomplete.html | 39 ---- .../incomplete.js | 26 --- .../identical-links-same-purpose/passes.html | 55 ------ .../identical-links-same-purpose/passes.js | 24 --- .../identical-links-same-purpose.html | 187 ++++++++++++++++++ .../identical-links-same-purpose.json | 66 +++++++ .../identical-links-same-purpose-matches.js | 48 ++--- 15 files changed, 547 insertions(+), 297 deletions(-) create mode 100644 test/checks/navigation/identical-links-same-purpose-after.js delete mode 100644 test/integration/full/identical-links-same-purpose/incomplete.html delete mode 100644 test/integration/full/identical-links-same-purpose/incomplete.js delete mode 100644 test/integration/full/identical-links-same-purpose/passes.html delete mode 100644 test/integration/full/identical-links-same-purpose/passes.js create mode 100644 test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html create mode 100644 test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index d583cc88ed..ff7a77295f 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -41,7 +41,7 @@ | html-has-lang | Ensures every HTML document has a lang attribute | Serious | cat.language, wcag2a, wcag311 | true | | html-lang-valid | Ensures the lang attribute of the <html> element has a valid value | Serious | cat.language, wcag2a, wcag311 | true | | html-xml-lang-mismatch | Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page | Moderate | cat.language, wcag2a, wcag311 | true | -| identical-links-same-purpose | Ensures that links with identical accessible names resolve to the same resource | Minor | wcag2aaa, wcag249, experimental | true | +| identical-links-same-purpose | Ensure that links with the same accessible name serve a similar purpose | Minor | wcag2aaa, wcag249, best-practice, experimental | true | | image-alt | Ensures <img> elements have alternate text or a role of none or presentation | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | | image-redundant-alt | Ensure image alternative is not repeated as text | Minor | cat.text-alternatives, best-practice | true | | input-button-name | Ensures input buttons have discernible text | Critical | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a | true | diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index f027e827b3..319189cbca 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -12,39 +12,137 @@ if (results.length < 2) { * - if not change `result` to `undefined` */ for (let index = 0; index < results.length; index++) { - const { accessibleText, linkResource } = results[index].data; - const identicalLinks = getIdenticalLinks(accessibleText, index); - - if (!hasSamePurpose(linkResource, identicalLinks)) { + const { data: nodeData } = results[index]; + const { name, resource } = nodeData; + const identicalNodes = getNodesWithIdenticalAccessibleName(name, index); + if (!identicalNodes.length) { + continue; + } + if (!resource) { + results[index].result = undefined; + continue; + } + if (!servesSamePurpose(resource, identicalNodes)) { results[index].result = undefined; + continue; } } return results; /** - * Get list of items from results which match a prescribed accessible name - * @param {String} expectedAccessibleText accessible name to be matched + * Get list of nodes from results which match a given accessible name + * @method getNodesWithIdenticalAccessibleName + * @param {String} expectedName accessible name to be matched * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration * @returns {Array} */ -function getIdenticalLinks(expectedAccessibleText, excludeIndex) { - return results.filter( - ({ data: { accessibleText } }, index) => - index !== excludeIndex && accessibleText === expectedAccessibleText - ); +function getNodesWithIdenticalAccessibleName(expectedName, excludeIndex) { + return results.filter((result, index) => { + const { + data: { name } + } = result; + return index !== excludeIndex && name === expectedName; + }); } /** - * Check if a given set of links have same resource - * @param {String} expectedResource resource string - * @param {Array} identicalLinks results where `data.accessibleText` were identical + * Check if a given set of nodes have same resource + * @method servesSamePurpose + * @param {String} expectedResource expected resource + * @param {Array} identicalNodes results where `data.accessibleText` were identical * @returns {Boolean} */ -function hasSamePurpose(expectedResource, identicalLinks) { - return identicalLinks.every(({ data: { linkResource } }) => { - const expected = expectedResource.replace(/\s/g, '').toLowerCase(); - const actual = linkResource.replace(/\s/g, '').toLowerCase(); - return expected === actual; +function servesSamePurpose(expectedResource, identicalNodes) { + const { parts: expected } = parseUri(expectedResource); + return identicalNodes.every(({ data }) => { + const { resource } = data; + const { isFile, parts: actual } = parseUri(resource); + return actual[isFile ? 'some' : 'every'](part => expected.includes(part)); }); } + +/** + * Parse a given URI and return to parts + * @method parseUri + * @param {String} uri uri resource + * @returns {Object} + */ +function parseUri(uri = '') { + const parser = document.createElement('a'); + parser.href = uri; + + const curatedPathname = stripLeadingAndTrailingSlash(parser.pathname); + const defaults = [ + parser.protocol, + parser.hostname, + parser.port, + parser.search + ]; + const pathname = parseUriPathnameAndHash(curatedPathname, parser.hash); + const file = parseUriPathnameAndFile(curatedPathname); + + let uriParts = [...defaults, ...pathname]; + if (file) { + uriParts = [...defaults, ...file]; + } + return { + isFile: !!file, + parts: uriParts.filter(item => !!item) + }; +} + +/** + * Parse a given pathname for filename & return an array containing parts of pathname and filename (exlcuding index if any) + * @method parseUriPathnameAndFile + * @param {String} pathname pathname of the given resource + * @returns {Array} + */ +function parseUriPathnameAndFile(pathname) { + const fileName = pathname.split('/').pop(); + if (fileName.indexOf('.') === -1) { + return undefined; + } + + const hasIndexInFilename = name => name.toLowerCase().includes('index.'); + const fileNameParts = pathname + .split('/') + .filter(part => !!part) + .filter(part => !hasIndexInFilename(part)); + + return fileNameParts; +} + +/** + * Parse a given URI hash and pathname + * @method parseUriPathnameAndHash + * @param {String} pathname pathname part of a given `uri` + * @param {String} hash hash part of a given `uri` + * @returns {Array} + */ +function parseUriPathnameAndHash(pathname, hash) { + /** + * if `hash` -> `hashbang` -or- `hash` is followed by `slash` + * - uri may resolve to different resource, return combination of pathname and hash + */ + if (hash && (hash.includes('#!/') || hash.includes('#/'))) { + return [pathname, hash]; + } + + /** + * `hash` is an `inline` anchor -> ignore + */ + return [pathname]; +} + +/** + * Remove trailing slashes (if any) from a given string + * @method stripLeadingAndTrailingSlash + * @param {String} str given string + * @returns {String} + */ +function stripLeadingAndTrailingSlash(str) { + return str + .replace(/^\/+/g, '') // remove leading slash + .replace(/\/+$/, ''); // remove trailing slash +} diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index b333e9973c..aab3f8ac9e 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -3,46 +3,28 @@ * `identical-links-same-purpose-after` fn, helps reconcile the results & alter the CheckResult accordingly */ const { text } = axe.commons; -const accessibleText = text.accessibleTextVirtual(virtualNode); -const linkResource = getLinkResource(node); -if (!linkResource) { +const accName = text.accessibleTextVirtual(virtualNode, { + includeHidden: true +}); +const accNameNoUnicode = text.removeUnicode(accName, { + emoji: true, + nonBmp: true, + punctuations: true +}); +const accNameSanitized = text.sanitize(accNameNoUnicode).toLowerCase(); + +if (!accNameSanitized) { return undefined; } /** * Set `data` for use in `after` fn */ -this.data({ - accessibleText, - linkResource -}); +const afterData = { + name: accNameSanitized, + resource: node.href +}; +this.data(afterData); return true; - -/** - * Get resource that a element points to (eg: href or location redirects) - * @param {HTMLElement} el Element - * @returns {String|null} - */ -function getLinkResource(el) { - if (el.hasAttribute('href')) { - return el.getAttribute('href'); - } - - const resourceAttr = Array.from(axe.utils.getNodeAttributes(node)).find( - ({ value }) => { - /** - * get any attribute which has `location.` or `location=` in its value - */ - const attrValue = value.replace(/\s/g, ''); - return /^location[\.\=]/.test(attrValue); - } - ); - - if (resourceAttr) { - return resourceAttr.value; - } - - return null; -} diff --git a/lib/checks/navigation/identical-links-same-purpose.json b/lib/checks/navigation/identical-links-same-purpose.json index 26716f3ccd..305bf45f4f 100644 --- a/lib/checks/navigation/identical-links-same-purpose.json +++ b/lib/checks/navigation/identical-links-same-purpose.json @@ -5,8 +5,8 @@ "metadata": { "impact": "minor", "messages": { - "pass": "Identical links (if any) resolve to same purpose", - "incomplete": "Fix purpose of links with same accessible name" + "pass": "There are no other links with the same name, that go to a different URL", + "incomplete": "Check that links have the same purpose, or are intentionally ambiguous." } } } diff --git a/lib/rules/identical-links-same-purpose-matches.js b/lib/rules/identical-links-same-purpose-matches.js index d0897dcade..93e6a8488f 100644 --- a/lib/rules/identical-links-same-purpose-matches.js +++ b/lib/rules/identical-links-same-purpose-matches.js @@ -1,6 +1,16 @@ const { aria, text } = axe.commons; const role = aria.getRole(node); -const accText = text.accessibleTextVirtual(virtualNode); +const hasAccName = !!text.accessibleTextVirtual(virtualNode, { + includeHidden: true +}); -return role === `link` && !!accText; +if (!hasAccName) { + return false; +} + +if (role && role !== 'link') { + return false; +} + +return true; diff --git a/lib/rules/identical-links-same-purpose.json b/lib/rules/identical-links-same-purpose.json index 5e48d560ae..de53f3ed20 100644 --- a/lib/rules/identical-links-same-purpose.json +++ b/lib/rules/identical-links-same-purpose.json @@ -1,11 +1,12 @@ { "id": "identical-links-same-purpose", - "selector": "a, area, [role=link]", + "selector": "a[href], area[href], [role=\"link\"]", + "excludeHidden": false, "matches": "identical-links-same-purpose-matches.js", - "tags": ["wcag2aaa", "wcag249", "experimental"], + "tags": ["wcag2aaa", "wcag249", "best-practice", "experimental"], "metadata": { - "description": "Ensures that links with identical accessible names resolve to the same resource", - "help": "Links with resolve to same or equivalent resources" + "description": "Ensure that links with the same accessible name serve a similar purpose", + "help": "Links with the same name have a similar purpose" }, "all": ["identical-links-same-purpose"], "any": [], diff --git a/test/checks/navigation/identical-links-same-purpose-after.js b/test/checks/navigation/identical-links-same-purpose-after.js new file mode 100644 index 0000000000..e825f4eb48 --- /dev/null +++ b/test/checks/navigation/identical-links-same-purpose-after.js @@ -0,0 +1,42 @@ +describe('identical-links-same-purpose-after tests', function() { + 'use strict'; + + var check = checks['identical-links-same-purpose']; + + it('sets results of check result to `undefined` if links do not serve identical purpose', function() { + var checkResults = [ + { + data: { name: 'follow us', resource: 'http://facebook.com' }, + result: true + }, + { + data: { name: 'follow us', resource: 'http://instagram.com' }, + result: true + } + ]; + var results = check.after(checkResults); + assert.lengthOf(results, 2); + assert.isUndefined(results[0].result); + assert.isUndefined(results[1].result); + }); + + it('sets results of check result to `true` if links serve identical purpose', function() { + var checkResults = [ + { + data: { name: 'Axe Core', resource: 'http://deque.com/axe-core' }, + result: true + }, + { + data: { + name: 'Axe Core', + resource: 'http://deque.com/axe-core/index.html' + }, + result: true + } + ]; + var results = check.after(checkResults); + assert.lengthOf(results, 2); + assert.isTrue(results[0].result); + assert.isTrue(results[1].result); + }); +}); diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js index 68ac311191..a0623d7b79 100644 --- a/test/checks/navigation/identical-links-same-purpose.js +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -12,8 +12,8 @@ describe('identical-links-same-purpose tests', function() { checkContext.reset(); }); - it('returns undefined when element does not have a resource (empty href)', function() { - var vNode = queryFixture('Go to google.com'); + it('returns undefined for native link with `href` but no accessible name', function() { + var vNode = queryFixture(''); var actual = check.evaluate.call( checkContext, vNode.actualNode, @@ -24,9 +24,21 @@ describe('identical-links-same-purpose tests', function() { assert.isNull(checkContext._data); }); - it('returns undefined when element does not have a resource (onclick does not change location)', function() { + it('returns undefined when ARIA link that has no accessible name', function() { + var vNode = queryFixture(''); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isUndefined(actual); + assert.isNull(checkContext._data); + }); + + it('returns undefined when native link has only emoji as accessible name', function() { var vNode = queryFixture( - 'Link text' + '🤘' ); var actual = check.evaluate.call( checkContext, @@ -38,9 +50,9 @@ describe('identical-links-same-purpose tests', function() { assert.isNull(checkContext._data); }); - it('returns true when element has location resource', function() { + it('returns undefined when native link has only nonBmp characters (diacritical marks supplement) as accessible name', function() { var vNode = queryFixture( - 'Follow us' + '' ); var actual = check.evaluate.call( checkContext, @@ -48,13 +60,13 @@ describe('identical-links-same-purpose tests', function() { options, vNode ); - assert.isTrue(actual); - assert.hasAllKeys(checkContext._data, ['accessibleText', 'linkResource']); + assert.isUndefined(actual); + assert.isNull(checkContext._data); }); - it('returns true when element has location resource', function() { + it('returns undefined when native link has only nonBmp characters (currency symbol) as accessible name', function() { var vNode = queryFixture( - 'Link text' + '' ); var actual = check.evaluate.call( checkContext, @@ -62,57 +74,73 @@ describe('identical-links-same-purpose tests', function() { options, vNode ); - assert.isTrue(actual); - assert.hasAllKeys(checkContext._data, ['accessibleText', 'linkResource']); + assert.isUndefined(actual); + assert.isNull(checkContext._data); }); - describe('after', function() { - it('sets results of check result to `undefined` if links do not serve identical purpose', function() { - var checkResults = [ - { - data: { - accessibleText: 'follow us', - linkResource: 'http://facebook.com' - }, - result: true - }, - { - data: { - accessibleText: 'follow us', - linkResource: 'http://instagram.com' - }, - result: true - } - ]; - var results = check.after(checkResults); + it('returns undefined when ARIA link has only punctuations as accessible name', function() { + var vNode = queryFixture(''); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isUndefined(actual); + assert.isNull(checkContext._data); + }); + + it('returns undefined when ARIA link has only combination of emoji, punctuations, nonBmp characters as accessible name', function() { + var vNode = queryFixture( + '' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isUndefined(actual); + assert.isNull(checkContext._data); + }); - assert.lengthOf(results, 2); - assert.isUndefined(results[0].result); - assert.isUndefined(results[1].result); - }); + it('returns true for native links with `href` and accessible name', function() { + var vNode = queryFixture('Pass 1'); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['name', 'resource']); + }); - it('sets results of check result to `true` if links serve identical purpose', function() { - var checkResults = [ - { - data: { - accessibleText: 'follow us', - linkResource: 'http://instagram.com/axe' - }, - result: true - }, - { - data: { - accessibleText: 'follow us', - linkResource: 'http://instagram.com/axe' - }, - result: true - } - ]; - var results = check.after(checkResults); + it('returns true for ARIA links has accessible name', function() { + var vNode = queryFixture( + '' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['name', 'resource']); + }); - assert.lengthOf(results, 2); - assert.isTrue(results[0].result); - assert.isTrue(results[1].result); - }); + it('returns true for native links with `href` and accessible name (that also has emoji, nonBmp and punctuation characters)', function() { + var vNode = queryFixture( + 'The ☀️ is orange, the ◓ is white.' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['name', 'resource']); }); }); diff --git a/test/integration/full/identical-links-same-purpose/incomplete.html b/test/integration/full/identical-links-same-purpose/incomplete.html deleted file mode 100644 index 4077c0a81c..0000000000 --- a/test/integration/full/identical-links-same-purpose/incomplete.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - identical-links-same-purpose test - - - - - - - - - - - - - - Contact - Contact - - - Follow us - Follow us - - - - - - diff --git a/test/integration/full/identical-links-same-purpose/incomplete.js b/test/integration/full/identical-links-same-purpose/incomplete.js deleted file mode 100644 index 75a7b67426..0000000000 --- a/test/integration/full/identical-links-same-purpose/incomplete.js +++ /dev/null @@ -1,26 +0,0 @@ -describe('identical-links-same-purpose incomplete test', function() { - 'use strict'; - - var runConfig = { - runOnly: { - type: 'rule', - values: ['identical-links-same-purpose'] - } - }; - - it('returns incomplete results and no passes', function(done) { - axe.run(runConfig, function(err, res) { - assert.isNull(err); - assert.isDefined(res); - - assert.lengthOf(res.passes, 0); - assert.lengthOf(res.incomplete, 1); - assert.lengthOf(res.incomplete[0].nodes, 4); - assert.equal( - res.incomplete[0].nodes[0].html, - 'Contact' - ); - done(); - }); - }); -}); diff --git a/test/integration/full/identical-links-same-purpose/passes.html b/test/integration/full/identical-links-same-purpose/passes.html deleted file mode 100644 index 8ee8c922fd..0000000000 --- a/test/integration/full/identical-links-same-purpose/passes.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - identical-links-same-purpose test - - - - - - - - - - -
- - - Contact - Contact - - - Contact Us - Contact Us - - - - Visit Netherlands - - - Visit Netherlands - - - - - - - diff --git a/test/integration/full/identical-links-same-purpose/passes.js b/test/integration/full/identical-links-same-purpose/passes.js deleted file mode 100644 index 556652f43a..0000000000 --- a/test/integration/full/identical-links-same-purpose/passes.js +++ /dev/null @@ -1,24 +0,0 @@ -describe('identical-links-same-purpose passes test', function() { - 'use strict'; - - var runConfig = { - runOnly: { - type: 'rule', - values: ['identical-links-same-purpose'] - } - }; - - it('returns incomplete results and no passes', function(done) { - axe.run(runConfig, function(err, res) { - assert.isNull(err); - assert.isDefined(res); - - assert.lengthOf(res.passes, 1); - assert.lengthOf(res.incomplete, 0); - assert.isAbove(res.passes[0].nodes.length, 6); - assert.deepEqual(res.passes[0].nodes[8].target, ['span:nth-child(7)']); - - done(); - }); - }); -}); diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html new file mode 100644 index 0000000000..61fd9f95bf --- /dev/null +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html @@ -0,0 +1,187 @@ + + +Pass 1 +Pass 1 + + +Pass 2 +Pass 2 +Pass 2 + + +Pass 3 +Pass 3 + + +Pass 4 +Pass 4 + + +Pass 5 +Pass 5 + + +Pass 6 +Pass 6 + + +Pass 7 +Pass 7 + + +Pass 8 +Pass 8 + + +Pass 9 +Pass 9 + + +Pass 10 +Pass 10 + + +Pass 11 +Pass 11, but different accessible name + + + + + + + + + + + + + + + + + +MDN infographic + + + +Incomplete 1 +Incomplete 1 + + +Incomplete 2 +Incomplete 2 + + +Incomplete 3 +Incomplete 3 + + + +
Incomplete 4
+ + + +
Incomplete 5
+ + + +Incomplete 6 + + +INCOMPLETE 7 +Incomplete 7 + + + Incomplete 8 +Incomplete 8 + + +Incomplete 9 >>> +Incomplete 9 + + +Incomplete 10! +Incomplete 10 + + +Incomplete 11! +🤘 Incomplete 11 🤘 + + +Incomplete 12 +Incomplete 12 + + + + + + + +MDN infographic + + +follow us +follow us diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json new file mode 100644 index 0000000000..a026dd64ee --- /dev/null +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json @@ -0,0 +1,66 @@ +{ + "description": "identical-links-same-purpose tests", + "rule": "identical-links-same-purpose", + "passes": [ + ["#pass1"], + ["#pass1-identical1"], + ["#pass2"], + ["#pass2-identical1"], + ["#pass2-identical2"], + ["#pass3"], + ["#pass3-identical1"], + ["#pass4"], + ["#pass4-identical1"], + ["#pass5"], + ["#pass5-identical1"], + ["#pass6"], + ["#pass6-identical1"], + ["#pass7"], + ["#pass7-identical1"], + ["#pass8"], + ["#pass8-identical1"], + ["#pass9"], + ["#pass9-identical1"], + ["#pass10"], + ["#pass10-identical1"], + ["#pass11"], + ["#pass11-not-identical1"], + ["#pass12"], + ["#pass12-identical1"], + ["#pass13"], + ["#pass13-identical1"], + ["#pass14"], + ["#pass14-identical1"] + ], + "incomplete": [ + ["#incomplete1"], + ["#incomplete1-non-identical1"], + ["#incomplete2"], + ["#incomplete2-non-identical1"], + ["#incomplete3"], + ["#incomplete3-non-identical1"], + ["#incomplete4"], + ["#incomplete4-identical1"], + ["#incomplete5"], + ["#incomplete5-identical1"], + ["#incomplete6"], + ["#incomplete6-identical1"], + ["#incomplete7"], + ["#incomplete7-identical1"], + ["#incomplete8"], + ["#incomplete8-identical1"], + ["#incomplete9"], + ["#incomplete9-identical1"], + ["#incomplete10"], + ["#incomplete10-identical1"], + ["#incomplete11"], + ["#incomplete11-identical1"], + ["#incomplete12"], + ["#incomplete12-identical1"], + ["#incomplete13"], + ["#incomplete13-identical1"], + ["#incomplete13-identical2"], + ["#incomplete14"], + ["#incomplete14-identical1"] + ] +} diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index 515d660277..41478b0af6 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -12,63 +12,43 @@ describe('identical-links-same-purpose-matches tests', function() { axe._tree = undefined; }); - it('returns false when
element has no implicit role', function() { - var vNode = queryFixture('
Some content
'); + it('returns false for native links without accessible name', function() { + var vNode = queryFixture(''); var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); - it('returns false when element has no role !== link', function() { - var vNode = queryFixture(''); - var actual = rule.matches(vNode.actualNode, vNode); - assert.isFalse(actual); - }); - - it('returns false when element no href attribute', function() { - var vNode = queryFixture('Go to google.com'); - var actual = rule.matches(vNode.actualNode, vNode); - assert.isFalse(actual); - }); - - it('returns true when element no href attribute but has role === link', function() { - var vNode = queryFixture('Go to google.com'); - var actual = rule.matches(vNode.actualNode, vNode); - assert.isTrue(actual); - }); - - it('returns true when element has href attribute (implicit role === link)', function() { + it('returns false for native links with a role !== link and an accessible name', function() { var vNode = queryFixture( - 'Go to google.com' + 'Go to Checkout' ); var actual = rule.matches(vNode.actualNode, vNode); - assert.isTrue(actual); + assert.isFalse(actual); }); - it('returns false when element has href attribute but no accessible name', function() { - var vNode = queryFixture(''); + it('returns false for ARIA links without accessible name', function() { + var vNode = queryFixture(''); var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); - it('returns false when element has no href attribute', function() { - var vNode = queryFixture( - 'MDN' - ); + it('returns false when native links (without href) has role === link and accessible name', function() { + var vNode = queryFixture(''); var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); - it('returns false when element has href attribute but no accessible name', function() { + it('returns true when ARIA links has accessible name ', function() { var vNode = queryFixture( - '' + '' ); var actual = rule.matches(vNode.actualNode, vNode); - assert.isFalse(actual); + assert.isTrue(actual); }); - it('returns true when element has href attribute and an accessible name', function() { + it('returns true when native links has both HREF and an accessible name', function() { var vNode = queryFixture( - '' + '' ); var actual = rule.matches(vNode.actualNode, vNode); assert.isTrue(actual); From 67e1dfa87a53e14bcd03ffcaf53a4614ea459b6f Mon Sep 17 00:00:00 2001 From: jkodu Date: Tue, 13 Aug 2019 15:43:39 +0100 Subject: [PATCH 11/53] docs: update rule descriptions --- doc/rule-descriptions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 4fc8da3cb5..a8876dc61a 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -41,6 +41,7 @@ | html-has-lang | Ensures every HTML document has a lang attribute | Serious | cat.language, wcag2a, wcag311 | true | | html-lang-valid | Ensures the lang attribute of the <html> element has a valid value | Serious | cat.language, wcag2a, wcag311 | true | | html-xml-lang-mismatch | Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page | Moderate | cat.language, wcag2a, wcag311 | true | +| identical-links-same-purpose | Ensure that links with the same accessible name serve a similar purpose | Minor | wcag2aaa, wcag249, best-practice, experimental | true | | image-alt | Ensures <img> elements have alternate text or a role of none or presentation | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | | image-redundant-alt | Ensure image alternative is not repeated as text | Minor | cat.text-alternatives, best-practice | true | | input-button-name | Ensures input buttons have discernible text | Critical | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a | true | From d1ec3665f9d3170c226e453898365068b129f50e Mon Sep 17 00:00:00 2001 From: jkodu Date: Thu, 15 Aug 2019 09:39:35 +0100 Subject: [PATCH 12/53] fix: edge case when identical name do not have resource --- lib/checks/navigation/identical-links-same-purpose-after.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index 319189cbca..3213709c91 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -56,7 +56,10 @@ function getNodesWithIdenticalAccessibleName(expectedName, excludeIndex) { function servesSamePurpose(expectedResource, identicalNodes) { const { parts: expected } = parseUri(expectedResource); return identicalNodes.every(({ data }) => { - const { resource } = data; + const { resource = undefined } = data; + if (!resource) { + return false; + } const { isFile, parts: actual } = parseUri(resource); return actual[isFile ? 'some' : 'every'](part => expected.includes(part)); }); From 2efa44e07257adb7a4aedd68b7437986b446edb6 Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 23 Aug 2019 13:02:18 +0100 Subject: [PATCH 13/53] test: update integration tests and add inapplicable tests --- .../identical-links-same-purpose.html | 61 ++++++++++++++++--- .../identical-links-same-purpose.json | 19 +----- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html index 61fd9f95bf..b6af3b4581 100644 --- a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html @@ -48,7 +48,7 @@ Pass 11 -Pass 11, but different accessible name @@ -58,8 +58,8 @@ Pass 12, but different accessible name - - + + - + @@ -102,17 +102,18 @@ /> + Incomplete 1 -Incomplete 1 +Incomplete 1 Incomplete 2 -Incomplete 2 +Incomplete 2 Incomplete 3 -Incomplete 3 +Incomplete 3 @@ -185,3 +186,49 @@ follow us follow us + + + + + + + + + + + + + + + + +Inapplicable 4 + + + + + +MDN infographic diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json index a026dd64ee..5e38d3ac28 100644 --- a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json @@ -24,7 +24,7 @@ ["#pass10"], ["#pass10-identical1"], ["#pass11"], - ["#pass11-not-identical1"], + ["#pass11-identical1"], ["#pass12"], ["#pass12-identical1"], ["#pass13"], @@ -34,33 +34,18 @@ ], "incomplete": [ ["#incomplete1"], - ["#incomplete1-non-identical1"], ["#incomplete2"], - ["#incomplete2-non-identical1"], ["#incomplete3"], - ["#incomplete3-non-identical1"], ["#incomplete4"], - ["#incomplete4-identical1"], ["#incomplete5"], - ["#incomplete5-identical1"], ["#incomplete6"], - ["#incomplete6-identical1"], ["#incomplete7"], - ["#incomplete7-identical1"], ["#incomplete8"], - ["#incomplete8-identical1"], ["#incomplete9"], - ["#incomplete9-identical1"], ["#incomplete10"], - ["#incomplete10-identical1"], ["#incomplete11"], - ["#incomplete11-identical1"], ["#incomplete12"], - ["#incomplete12-identical1"], ["#incomplete13"], - ["#incomplete13-identical1"], - ["#incomplete13-identical2"], - ["#incomplete14"], - ["#incomplete14-identical1"] + ["#incomplete14"] ] } From 13222ed2ef05df5882bf62f02c34fafdf0492c14 Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 23 Aug 2019 13:04:01 +0100 Subject: [PATCH 14/53] fix: update matches, check and the tests --- .../identical-links-same-purpose.js | 32 ++++----- .../identical-links-same-purpose-matches.js | 67 +++++++++++++++++-- .../identical-links-same-purpose.js | 7 ++ .../identical-links-same-purpose-matches.js | 58 +++++++++++++--- 4 files changed, 133 insertions(+), 31 deletions(-) diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index aab3f8ac9e..0ba4cf0e79 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -1,30 +1,32 @@ /** - * Note: - * `identical-links-same-purpose-after` fn, helps reconcile the results & alter the CheckResult accordingly + * Note: `identical-links-same-purpose-after` fn, helps reconcile the results */ const { text } = axe.commons; -const accName = text.accessibleTextVirtual(virtualNode, { - includeHidden: true -}); -const accNameNoUnicode = text.removeUnicode(accName, { - emoji: true, - nonBmp: true, - punctuations: true -}); -const accNameSanitized = text.sanitize(accNameNoUnicode).toLowerCase(); - -if (!accNameSanitized) { +const name = getCuratedAccessibleName(virtualNode); +if (!name) { return undefined; } /** - * Set `data` for use in `after` fn + * Set `data` and `relatedNodes` for use in `after` fn */ const afterData = { - name: accNameSanitized, + name, resource: node.href }; this.data(afterData); +this.relatedNodes([node]); return true; + +// todo:jey +function getCuratedAccessibleName(vNode) { + const accText = text.accessibleTextVirtual(vNode); + const accTextNoUnicode = text.removeUnicode(accText, { + emoji: true, + nonBmp: true, + punctuations: true + }); + return text.sanitize(accTextNoUnicode).toLowerCase(); +} diff --git a/lib/rules/identical-links-same-purpose-matches.js b/lib/rules/identical-links-same-purpose-matches.js index 93e6a8488f..36d372f519 100644 --- a/lib/rules/identical-links-same-purpose-matches.js +++ b/lib/rules/identical-links-same-purpose-matches.js @@ -1,16 +1,71 @@ -const { aria, text } = axe.commons; - -const role = aria.getRole(node); -const hasAccName = !!text.accessibleTextVirtual(virtualNode, { - includeHidden: true -}); +const { aria, text, dom } = axe.commons; +const { escapeSelector } = axe.utils; +const hasAccName = !!text.accessibleTextVirtual(virtualNode); if (!hasAccName) { return false; } +const role = aria.getRole(node); if (role && role !== 'link') { return false; } +const nodeName = node.nodeName.toUpperCase(); +if (['A', 'AREA'].includes(nodeName) && !node.hasAttribute('href')) { + return false; +} + +if (nodeName === 'AREA') { + const rootEl = dom.getRootNode(node); + /** + * Ensure map is used by `img` witin the document tree + */ + const mapEl = getMapForAreaElement(node, rootEl); + if (!mapEl) { + return false; + } + + const mapElName = mapEl.getAttribute('name'); + if (!mapElName) { + return false; + } + + return isMapReferredByAnyImgNode(rootEl, mapElName); +} + return true; + +/** + * Check if a given name is referred by node within the `usemap` attribute + * @param {HTMLElement} rootEl root node (document or shadowRoot) + * @param {String} mapElName element name + * @reurns {Boolean} + */ +function isMapReferredByAnyImgNode(rootEl, mapElName) { + const mapName = escapeSelector(mapElName); + const refs = rootEl.querySelectorAll(`[usemap="#${mapName}"]`); + return refs.length > 0; +} + +/** + * Get element for a given element + * @method getMapForAreaElement + * @param {HTMLElement} areaEl element + * @param {HTMLElement} rootEl root node (document or shadowRoot) + * @returns {HTMLElement|undefined} + */ +function getMapForAreaElement(areaEl, rootEl) { + const parent = areaEl.assignedSlot ? areaEl.assignedSlot : areaEl.parentNode; + + if (!parent || parent === rootEl) { + return undefined; + } + + const parentNodeName = parent.nodeName.toUpperCase(); + if (parentNodeName !== 'MAP') { + getMapForAreaElement(parent); + } + + return parent; +} diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js index a0623d7b79..388976dd18 100644 --- a/test/checks/navigation/identical-links-same-purpose.js +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -114,6 +114,8 @@ describe('identical-links-same-purpose tests', function() { ); assert.isTrue(actual); assert.hasAllKeys(checkContext._data, ['name', 'resource']); + assert.equal(checkContext._data.name, 'Pass 1'); + assert.equal(checkContext._data.resource, '/home/#/foo'); }); it('returns true for ARIA links has accessible name', function() { @@ -128,6 +130,9 @@ describe('identical-links-same-purpose tests', function() { ); assert.isTrue(actual); assert.hasAllKeys(checkContext._data, ['name', 'resource']); + assert.equal(checkContext._data.name, 'MDN'); + // todo:jey + assert.isUndefined(checkContext._data.resource); }); it('returns true for native links with `href` and accessible name (that also has emoji, nonBmp and punctuation characters)', function() { @@ -142,5 +147,7 @@ describe('identical-links-same-purpose tests', function() { ); assert.isTrue(actual); assert.hasAllKeys(checkContext._data, ['name', 'resource']); + assert.equal(checkContext._data.name, 'The ☀️ is orange, the ◓ is white.'); + assert.equal(checkContext._data.resource, '/home/#/foo'); }); }); diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index 41478b0af6..359f612623 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -12,13 +12,25 @@ describe('identical-links-same-purpose-matches tests', function() { axe._tree = undefined; }); - it('returns false for native links without accessible name', function() { + it('returns false when native link without accessible name', function() { var vNode = queryFixture(''); var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); - it('returns false for native links with a role !== link and an accessible name', function() { + it('returns false for ARIA link without accessible name', function() { + var vNode = queryFixture(''); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isFalse(actual); + }); + + it('returns false when native link without href', function() { + var vNode = queryFixture('Book Now'); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isFalse(actual); + }); + + it('returns false for native link with a role !== link', function() { var vNode = queryFixture( 'Go to Checkout' ); @@ -26,29 +38,55 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isFalse(actual); }); - it('returns false for ARIA links without accessible name', function() { - var vNode = queryFixture(''); + it('returns false when `area[href]` has no parent `map` element', function() { + var vNode = queryFixture( + '' + ); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isFalse(actual); + }); + + it('returns false when `area[href]` has parent `map` that is not referred by `img[usemap]`', function() { + var vNode = queryFixture( + '' + + '' + + '' + ); var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); - it('returns false when native links (without href) has role === link and accessible name', function() { - var vNode = queryFixture(''); + it('returns false when `area[href]` has no href', function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); var actual = rule.matches(vNode.actualNode, vNode); assert.isFalse(actual); }); - it('returns true when ARIA links has accessible name ', function() { + it('returns true when native link has an accessible name', function() { var vNode = queryFixture( - '' + '' ); var actual = rule.matches(vNode.actualNode, vNode); assert.isTrue(actual); }); - it('returns true when native links has both HREF and an accessible name', function() { + it('returns true for ARIA link has an accessible name', function() { + var vNode = queryFixture('Book Tour'); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isTrue(actual); + }); + + it('returns true when `area[href]` has parent `map` that is referred by `img`', function() { var vNode = queryFixture( - '' + '' + + '' + + '' + + 'MDN infographic' ); var actual = rule.matches(vNode.actualNode, vNode); assert.isTrue(actual); From 05b8d3a4a9c93c8035553f8cbeaced3ed0ab0688 Mon Sep 17 00:00:00 2001 From: jkodu Date: Mon, 26 Aug 2019 06:39:18 +0100 Subject: [PATCH 15/53] update impl --- .../identical-links-same-purpose-after.js | 218 ++++++++---------- .../identical-links-same-purpose.js | 109 ++++++++- .../identical-links-same-purpose-matches.js | 10 +- .../identical-links-same-purpose-after.js | 135 ++++++++--- .../identical-links-same-purpose.js | 25 +- 5 files changed, 336 insertions(+), 161 deletions(-) diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index 3213709c91..59c6cc6119 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -1,5 +1,5 @@ /** - * Skip unless there are more than a single result + * Skip, as no results to curate */ if (results.length < 2) { return results; @@ -9,143 +9,121 @@ if (results.length < 2) { * for each result * - get other results with matching accessible name * - check if same purpose is served - * - if not change `result` to `undefined` + * - if not change `result` to `undefined` + * + * - construct a list of unique results to return */ +const uniqueResults = []; +const getIdenticalNodesWithSameName = identicalNodesMapper(); + for (let index = 0; index < results.length; index++) { - const { data: nodeData } = results[index]; - const { name, resource } = nodeData; - const identicalNodes = getNodesWithIdenticalAccessibleName(name, index); - if (!identicalNodes.length) { - continue; - } - if (!resource) { - results[index].result = undefined; - continue; - } - if (!servesSamePurpose(resource, identicalNodes)) { - results[index].result = undefined; + const currentResult = results[index]; + const { data } = currentResult; + const { name, parsedResource } = data; + const identicalNodes = getIdenticalNodesWithSameName(results, name, index); + + /** + * if no identical nodes + * -> pass result + * + * when identical nodes exists + * -> but do not resolve to same purpose -or- do not have parsedResource + * Flag result as `incomplete` + * -> deduplicate results + * -> and add as relatedNodes + */ + if ( + identicalNodes.length && + (!parsedResource || !isIdenticalResource(parsedResource, identicalNodes)) + ) { + const matchedResult = uniqueResults.find( + result => result.data.name === name + ); + if (matchedResult) { + continue; + } + + currentResult.result = undefined; + currentResult.relatedNodes = []; + const relatedNodesOfIdenticalNodes = identicalNodes + .map(node => node.relatedNodes[0]) + .filter(item => !!item); + currentResult.relatedNodes.push(...relatedNodesOfIdenticalNodes); + + uniqueResults.push(currentResult); continue; } -} - -return results; -/** - * Get list of nodes from results which match a given accessible name - * @method getNodesWithIdenticalAccessibleName - * @param {String} expectedName accessible name to be matched - * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration - * @returns {Array} - */ -function getNodesWithIdenticalAccessibleName(expectedName, excludeIndex) { - return results.filter((result, index) => { - const { - data: { name } - } = result; - return index !== excludeIndex && name === expectedName; - }); + /** + * Pass result + */ + currentResult.relatedNodes = []; + uniqueResults.push(currentResult); } -/** - * Check if a given set of nodes have same resource - * @method servesSamePurpose - * @param {String} expectedResource expected resource - * @param {Array} identicalNodes results where `data.accessibleText` were identical - * @returns {Boolean} - */ -function servesSamePurpose(expectedResource, identicalNodes) { - const { parts: expected } = parseUri(expectedResource); - return identicalNodes.every(({ data }) => { - const { resource = undefined } = data; - if (!resource) { - return false; - } - const { isFile, parts: actual } = parseUri(resource); - return actual[isFile ? 'some' : 'every'](part => expected.includes(part)); - }); -} +return uniqueResults; /** - * Parse a given URI and return to parts - * @method parseUri - * @param {String} uri uri resource - * @returns {Object} + * Helper function to cache key value pair of accessible name vs identical results + * + * @mehod identicalNodesMapper + * @returns {Function} */ -function parseUri(uri = '') { - const parser = document.createElement('a'); - parser.href = uri; +function identicalNodesMapper() { + const nameMap = {}; - const curatedPathname = stripLeadingAndTrailingSlash(parser.pathname); - const defaults = [ - parser.protocol, - parser.hostname, - parser.port, - parser.search - ]; - const pathname = parseUriPathnameAndHash(curatedPathname, parser.hash); - const file = parseUriPathnameAndFile(curatedPathname); + /** + * Get list of nodes from results which match a given accessible name + * + * @param {Array} afterResults results passed to the after fn + * @param {String} expectedName accessible name to be matched + * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration + * @returns {Array} + */ + return function getIdenticalNodesWithSameName( + afterResults, + expectedName, + excludeIndex + ) { + if (nameMap[expectedName]) { + return nameMap[expectedName]; + } + const nodes = afterResults.filter(({ data }, index) => { + const { name } = data; + return index !== excludeIndex && name === expectedName; + }); - let uriParts = [...defaults, ...pathname]; - if (file) { - uriParts = [...defaults, ...file]; - } - return { - isFile: !!file, - parts: uriParts.filter(item => !!item) + nameMap[expectedName] === nodes; + return nodes; }; } /** - * Parse a given pathname for filename & return an array containing parts of pathname and filename (exlcuding index if any) - * @method parseUriPathnameAndFile - * @param {String} pathname pathname of the given resource - * @returns {Array} + * Verify if parsed resource match against all given identical nodes + * + * @param {Object} expectedResource parsed resource to be matched against + * @param {Array} identicalNodes nodes with identical accessible name whose parsed resource is checked for identity + * @returns {Boolean} */ -function parseUriPathnameAndFile(pathname) { - const fileName = pathname.split('/').pop(); - if (fileName.indexOf('.') === -1) { - return undefined; - } - - const hasIndexInFilename = name => name.toLowerCase().includes('index.'); - const fileNameParts = pathname - .split('/') - .filter(part => !!part) - .filter(part => !hasIndexInFilename(part)); - - return fileNameParts; -} +function isIdenticalResource(expectedResource, identicalNodes) { + return identicalNodes.every(({ data }) => { + const { parsedResource } = data; + if (!parsedResource) { + return false; + } -/** - * Parse a given URI hash and pathname - * @method parseUriPathnameAndHash - * @param {String} pathname pathname part of a given `uri` - * @param {String} hash hash part of a given `uri` - * @returns {Array} - */ -function parseUriPathnameAndHash(pathname, hash) { - /** - * if `hash` -> `hashbang` -or- `hash` is followed by `slash` - * - uri may resolve to different resource, return combination of pathname and hash - */ - if (hash && (hash.includes('#!/') || hash.includes('#/'))) { - return [pathname, hash]; - } + // remove key's whose values are undefined + const keysWithValues = Object.keys(parsedResource).filter( + key => !!parsedResource[key] + ); - /** - * `hash` is an `inline` anchor -> ignore - */ - return [pathname]; -} + // ensure every key/ value in resources match + const result = keysWithValues.every(key => { + const actual = parsedResource[key]; + const expected = expectedResource[key]; + return actual === expected; + }); -/** - * Remove trailing slashes (if any) from a given string - * @method stripLeadingAndTrailingSlash - * @param {String} str given string - * @returns {String} - */ -function stripLeadingAndTrailingSlash(str) { - return str - .replace(/^\/+/g, '') // remove leading slash - .replace(/\/+$/, ''); // remove trailing slash + return result; + }); } diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index 0ba4cf0e79..13c5ba841e 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -11,18 +11,32 @@ if (!name) { /** * Set `data` and `relatedNodes` for use in `after` fn */ + const afterData = { name, - resource: node.href + parsedResource: getParsedResource(node) }; this.data(afterData); this.relatedNodes([node]); return true; -// todo:jey +/** + * Get accessible name of a given virtual node + * -> excluding unicode + * -> trim whitespace + * -> transformed to lowercase + * @param {Object} vNode virtual node + * @returns {String} + */ function getCuratedAccessibleName(vNode) { - const accText = text.accessibleTextVirtual(vNode); + /** + * Note: + * Firefox does not respect map > area as visible, + * even when used by a img[usemap], hence the usage of `includeHidden` flag, + * in the accessible name computation + */ + const accText = text.accessibleTextVirtual(vNode, { includeHidden: true }); const accTextNoUnicode = text.removeUnicode(accText, { emoji: true, nonBmp: true, @@ -30,3 +44,92 @@ function getCuratedAccessibleName(vNode) { }); return text.sanitize(accTextNoUnicode).toLowerCase(); } + +/** + * Construct a resource object for a given node based uri/href of the node + * @param {HTMLElement} currentNode node + * @returns {Object} + */ +function getParsedResource(currentNode) { + if (!currentNode.href) { + return undefined; + } + + const nodeName = currentNode.nodeName.toUpperCase(); + let parser = currentNode; + + if (!['A', 'AREA'].includes(nodeName)) { + parser = document.createElement('a'); + parser.href = currentNode.href; + } + + const [pathname, filename] = getPathnameAndFilename(parser.pathname); + + return { + protocol: parser.prootocol, + hostname: parser.hostname, + port: parser.port, + pathname: removeLeadingAndTrialingSlash(pathname), + search: parser.search, + hash: getHash(parser.hash), + filename + }; +} + +/** + * Remove leading and trailing slashes of a given text (if any) + * @method removeLeadingAndTrialingSlash + * @param {String} str string + * @returns {String} + */ +function removeLeadingAndTrialingSlash(str) { + return str.replace(/^\/|\/$/g, ''); +} + +/** + * Interpret a given hash + * if `hash` + * -> is `hashbang` -or- `hash` is followed by `slash` + * -> it resolves to a different resource + * + * @method getHash + * @param {String} hash hash component of a parsed uri + * @returns {String|undefined} + */ +function getHash(hash) { + if (!hash) { + return undefined; + } + + if (!(hash.includes('#!/') || hash.includes('#/'))) { + return undefined; + } + + return hash; +} + +/** + * Resolve if a given pathname has filename & resolve the same as parts + * @param {String} pathname pathname part of a given uri + * @returns {Array} + */ +function getPathnameAndFilename(pathname) { + if (!pathname) { + return; + } + const filename = pathname.split('/').pop(); + if (!filename) { + return [pathname]; + } + + if (filename.indexOf('.') === -1) { + return [pathname]; + } + + const hasIndexInFilename = /index./.test(filename); + if (hasIndexInFilename) { + return [pathname.replace(filename, '')]; + } + + return [filename]; +} diff --git a/lib/rules/identical-links-same-purpose-matches.js b/lib/rules/identical-links-same-purpose-matches.js index 36d372f519..6bb6575371 100644 --- a/lib/rules/identical-links-same-purpose-matches.js +++ b/lib/rules/identical-links-same-purpose-matches.js @@ -1,7 +1,15 @@ const { aria, text, dom } = axe.commons; const { escapeSelector } = axe.utils; -const hasAccName = !!text.accessibleTextVirtual(virtualNode); +/** + * Note: + * Firefox does not respect map > area as visible, + * even when used by a img[usemap], hence the usage of `includeHidden` flag, + * in the accessible name computation + */ +const hasAccName = !!text.accessibleTextVirtual(virtualNode, { + includeHidden: true +}); if (!hasAccName) { return false; } diff --git a/test/checks/navigation/identical-links-same-purpose-after.js b/test/checks/navigation/identical-links-same-purpose-after.js index e825f4eb48..8530a95d47 100644 --- a/test/checks/navigation/identical-links-same-purpose-after.js +++ b/test/checks/navigation/identical-links-same-purpose-after.js @@ -1,42 +1,125 @@ describe('identical-links-same-purpose-after tests', function() { 'use strict'; + var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; var check = checks['identical-links-same-purpose']; + afterEach(function() { + fixture.innerHTML = ''; + }); + it('sets results of check result to `undefined` if links do not serve identical purpose', function() { - var checkResults = [ - { - data: { name: 'follow us', resource: 'http://facebook.com' }, - result: true + var nodeOne = queryFixture( + "follow us" + ); + var nodeOneData = { + data: { + name: 'follow us', + parsedResource: { + filename: undefined, + hash: undefined, + hostname: 'facebook.com', + pathname: '', + port: '', + protocol: undefined, + search: '' + } + }, + relatedNodes: [nodeOne], + result: true + }; + var nodeTwo = queryFixture( + "follow us" + ); + var nodeTwoData = { + data: { + name: 'follow us', + parsedResource: { + filename: undefined, + hash: undefined, + hostname: 'instagram.com', + pathname: '', + port: '', + protocol: undefined, + search: '' + } }, - { - data: { name: 'follow us', resource: 'http://instagram.com' }, - result: true - } - ]; + relatedNodes: [nodeTwo], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + var results = check.after(checkResults); - assert.lengthOf(results, 2); - assert.isUndefined(results[0].result); - assert.isUndefined(results[1].result); + + assert.lengthOf(results, 1); + + var result = results[0]; + assert.equal(result.data.name, nodeOneData.data.name); + assert.equal( + result.data.parsedResource.hostname, + nodeOneData.data.parsedResource.hostname + ); + assert.equal(result.relatedNodes.length, 1); + assert.isUndefined(result.result); }); it('sets results of check result to `true` if links serve identical purpose', function() { - var checkResults = [ - { - data: { name: 'Axe Core', resource: 'http://deque.com/axe-core' }, - result: true + var nodeOne = queryFixture( + "Axe Core" + ); + var nodeOneData = { + data: { + name: 'Axe Core', + parsedResource: { + filename: undefined, + hash: undefined, + hostname: 'deque.com', + pathname: 'axe-core', + port: '', + protocol: undefined, + search: '' + } + }, + relatedNodes: [nodeOne], + result: true + }; + var nodeTwo = queryFixture( + "Axe Core" + ); + var nodeTwoData = { + data: { + name: 'Axe Core us', + parsedResource: { + filename: undefined, + hash: undefined, + hostname: 'deque.com', + pathname: 'axe-core', + port: '', + protocol: undefined, + search: '' + } }, - { - data: { - name: 'Axe Core', - resource: 'http://deque.com/axe-core/index.html' - }, - result: true - } - ]; + relatedNodes: [nodeTwo], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + var results = check.after(checkResults); + assert.lengthOf(results, 2); - assert.isTrue(results[0].result); - assert.isTrue(results[1].result); + + var result1 = results[0]; + assert.equal(result1.data.name, nodeOneData.data.name); + assert.equal( + result1.data.parsedResource.pathname, + nodeOneData.data.parsedResource.pathname + ); + assert.equal(result1.relatedNodes.length, 0); + assert.isTrue(result1.result); + + var result2 = results[1]; + assert.equal(result2.relatedNodes.length, 0); + assert.isTrue(result2.result); }); }); diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js index 388976dd18..cc3093919a 100644 --- a/test/checks/navigation/identical-links-same-purpose.js +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -113,9 +113,10 @@ describe('identical-links-same-purpose tests', function() { vNode ); assert.isTrue(actual); - assert.hasAllKeys(checkContext._data, ['name', 'resource']); - assert.equal(checkContext._data.name, 'Pass 1'); - assert.equal(checkContext._data.resource, '/home/#/foo'); + assert.hasAllKeys(checkContext._data, ['name', 'parsedResource']); + assert.equal(checkContext._data.name, 'Pass 1'.toLowerCase()); + assert.equal(checkContext._data.parsedResource.hash, '#/foo'); + assert.equal(checkContext._data.parsedResource.pathname, 'home'); }); it('returns true for ARIA links has accessible name', function() { @@ -129,15 +130,14 @@ describe('identical-links-same-purpose tests', function() { vNode ); assert.isTrue(actual); - assert.hasAllKeys(checkContext._data, ['name', 'resource']); - assert.equal(checkContext._data.name, 'MDN'); - // todo:jey - assert.isUndefined(checkContext._data.resource); + assert.hasAllKeys(checkContext._data, ['name', 'parsedResource']); + assert.equal(checkContext._data.name, 'MDN'.toLowerCase()); + assert.isFalse(!!checkContext._data.resource); }); it('returns true for native links with `href` and accessible name (that also has emoji, nonBmp and punctuation characters)', function() { var vNode = queryFixture( - 'The ☀️ is orange, the ◓ is white.' + 'The ☀️ is orange, the ◓ is white.' ); var actual = check.evaluate.call( checkContext, @@ -146,8 +146,11 @@ describe('identical-links-same-purpose tests', function() { vNode ); assert.isTrue(actual); - assert.hasAllKeys(checkContext._data, ['name', 'resource']); - assert.equal(checkContext._data.name, 'The ☀️ is orange, the ◓ is white.'); - assert.equal(checkContext._data.resource, '/home/#/foo'); + assert.hasAllKeys(checkContext._data, ['name', 'parsedResource']); + assert.equal( + checkContext._data.name, + 'The is orange the is white'.toLowerCase() + ); + assert.equal(checkContext._data.parsedResource.pathname, 'foo.html'); }); }); From 6b69bcde672a2e2c08c16350ecd538f495076707 Mon Sep 17 00:00:00 2001 From: jkodu Date: Mon, 26 Aug 2019 07:25:44 +0100 Subject: [PATCH 16/53] test: shadowDOM tests --- .../identical-links-same-purpose.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js index cc3093919a..d422fd3756 100644 --- a/test/checks/navigation/identical-links-same-purpose.js +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -2,6 +2,8 @@ describe('identical-links-same-purpose tests', function() { 'use strict'; var fixture = document.getElementById('fixture'); + var shadowSupported = axe.testUtils.shadowSupport.v1; + var shadowCheckSetup = axe.testUtils.shadowCheckSetup; var queryFixture = axe.testUtils.queryFixture; var check = checks['identical-links-same-purpose']; var checkContext = axe.testUtils.MockCheckContext(); @@ -10,6 +12,7 @@ describe('identical-links-same-purpose tests', function() { afterEach(function() { fixture.innerHTML = ''; checkContext.reset(); + axe._tree = undefined; }); it('returns undefined for native link with `href` but no accessible name', function() { @@ -153,4 +156,61 @@ describe('identical-links-same-purpose tests', function() { ); assert.equal(checkContext._data.parsedResource.pathname, 'foo.html'); }); + + (shadowSupported ? it : xit)( + 'returns undefined for native link (in shadowDOM) with `href` but no accessible name', + function() { + var params = shadowCheckSetup( + '
', + '' + ); + var actual = check.evaluate.apply(checkContext, params); + assert.isUndefined(actual); + assert.isNull(checkContext._data); + } + ); + + (shadowSupported ? it : xit)( + 'returns true for native links (in shadowDOM) with `href` and accessible name', + function() { + var params = shadowCheckSetup( + '
', + 'Pass 1' + ); + var actual = check.evaluate.apply(checkContext, params); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['name', 'parsedResource']); + assert.equal(checkContext._data.name, 'Pass 1'.toLowerCase()); + assert.equal(checkContext._data.parsedResource.hash, '#/foo'); + assert.equal(checkContext._data.parsedResource.pathname, 'home'); + } + ); + + (shadowSupported ? it : xit)( + 'returns undefined when ARIA link (in shadowDOM) has only punctuations as accessible name', + function() { + var params = shadowCheckSetup( + '
', + '' + ); + var actual = check.evaluate.apply(checkContext, params); + assert.isUndefined(actual); + assert.isNull(checkContext._data); + } + ); + + (shadowSupported ? it : xit)( + 'returns true for ARIA links (in shadowDOM) has accessible name', + function() { + var params = shadowCheckSetup( + '
', + '' + ); + var actual = check.evaluate.apply(checkContext, params); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['name', 'parsedResource']); + assert.equal(checkContext._data.name, 'MDN'.toLowerCase()); + assert.isFalse(!!checkContext._data.resource); + } + ); }); From 4311d780dd2058455109107b980b13a9065503ae Mon Sep 17 00:00:00 2001 From: jkodu Date: Mon, 26 Aug 2019 07:42:45 +0100 Subject: [PATCH 17/53] test: add after tests --- .../identical-links-same-purpose-after.js | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/test/checks/navigation/identical-links-same-purpose-after.js b/test/checks/navigation/identical-links-same-purpose-after.js index 8530a95d47..177c784753 100644 --- a/test/checks/navigation/identical-links-same-purpose-after.js +++ b/test/checks/navigation/identical-links-same-purpose-after.js @@ -9,22 +9,14 @@ describe('identical-links-same-purpose-after tests', function() { fixture.innerHTML = ''; }); - it('sets results of check result to `undefined` if links do not serve identical purpose', function() { + it('sets results of check result to `undefined` if native links do not serve identical purpose', function() { var nodeOne = queryFixture( "follow us" ); var nodeOneData = { data: { name: 'follow us', - parsedResource: { - filename: undefined, - hash: undefined, - hostname: 'facebook.com', - pathname: '', - port: '', - protocol: undefined, - search: '' - } + parsedResource: { hostname: 'facebook.com' } }, relatedNodes: [nodeOne], result: true @@ -35,15 +27,7 @@ describe('identical-links-same-purpose-after tests', function() { var nodeTwoData = { data: { name: 'follow us', - parsedResource: { - filename: undefined, - hash: undefined, - hostname: 'instagram.com', - pathname: '', - port: '', - protocol: undefined, - search: '' - } + parsedResource: { hostname: 'instagram.com' } }, relatedNodes: [nodeTwo], result: true @@ -64,22 +48,14 @@ describe('identical-links-same-purpose-after tests', function() { assert.isUndefined(result.result); }); - it('sets results of check result to `true` if links serve identical purpose', function() { + it('sets results of check result to `true` if native links serve identical purpose', function() { var nodeOne = queryFixture( "Axe Core" ); var nodeOneData = { data: { name: 'Axe Core', - parsedResource: { - filename: undefined, - hash: undefined, - hostname: 'deque.com', - pathname: 'axe-core', - port: '', - protocol: undefined, - search: '' - } + parsedResource: { hostname: 'deque.com', pathname: 'axe-core' } }, relatedNodes: [nodeOne], result: true @@ -90,15 +66,7 @@ describe('identical-links-same-purpose-after tests', function() { var nodeTwoData = { data: { name: 'Axe Core us', - parsedResource: { - filename: undefined, - hash: undefined, - hostname: 'deque.com', - pathname: 'axe-core', - port: '', - protocol: undefined, - search: '' - } + parsedResource: { hostname: 'deque.com', pathname: 'axe-core' } }, relatedNodes: [nodeTwo], result: true @@ -122,4 +90,36 @@ describe('identical-links-same-purpose-after tests', function() { assert.equal(result2.relatedNodes.length, 0); assert.isTrue(result2.result); }); + + it('sets results of check result to `true` if ARIA links have different accessible names', function() { + var nodeOne = queryFixture(''); + var nodeOneData = { + data: { + name: 'earth', + parsedResource: {} + }, + relatedNodes: [nodeOne], + result: true + }; + var nodeTwo = queryFixture(''); + var nodeTwoData = { + data: { + name: 'venus', + parsedResource: {} + }, + relatedNodes: [nodeTwo], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + var results = check.after(checkResults); + assert.lengthOf(results, 2); + + var result1 = results[0]; + assert.equal(result1.data.name, nodeOneData.data.name); + assert.equal(result1.relatedNodes.length, 0); + + var result2 = results[1]; + assert.equal(result2.data.name, nodeTwoData.data.name); + assert.equal(result2.relatedNodes.length, 0); + }); }); From 6771316c36357cb5eb02953bae7a117ba95bf5e5 Mon Sep 17 00:00:00 2001 From: jkodu Date: Mon, 26 Aug 2019 13:56:49 +0100 Subject: [PATCH 18/53] refactor: helper methods that can be extracted to commons namespace later --- .../identical-links-same-purpose-after.js | 8 +- .../identical-links-same-purpose.js | 135 ++++++++++-------- 2 files changed, 76 insertions(+), 67 deletions(-) diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index 59c6cc6119..ea7d8dcef5 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -10,7 +10,6 @@ if (results.length < 2) { * - get other results with matching accessible name * - check if same purpose is served * - if not change `result` to `undefined` - * * - construct a list of unique results to return */ const uniqueResults = []; @@ -23,9 +22,6 @@ for (let index = 0; index < results.length; index++) { const identicalNodes = getIdenticalNodesWithSameName(results, name, index); /** - * if no identical nodes - * -> pass result - * * when identical nodes exists * -> but do not resolve to same purpose -or- do not have parsedResource * Flag result as `incomplete` @@ -65,7 +61,6 @@ return uniqueResults; /** * Helper function to cache key value pair of accessible name vs identical results - * * @mehod identicalNodesMapper * @returns {Function} */ @@ -74,7 +69,6 @@ function identicalNodesMapper() { /** * Get list of nodes from results which match a given accessible name - * * @param {Array} afterResults results passed to the after fn * @param {String} expectedName accessible name to be matched * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration @@ -100,7 +94,7 @@ function identicalNodesMapper() { /** * Verify if parsed resource match against all given identical nodes - * + * @method isIdenticalResource * @param {Object} expectedResource parsed resource to be matched against * @param {Array} identicalNodes nodes with identical accessible name whose parsed resource is checked for identity * @returns {Boolean} diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index 13c5ba841e..fc40604ee4 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -3,7 +3,22 @@ */ const { text } = axe.commons; -const name = getCuratedAccessibleName(virtualNode); +/** + * Note: + * Firefox does not respect map > area as visible, + * even when used by a img[usemap], hence the usage of `includeHidden` flag, + * in the accessible name computation + */ +const accText = text.accessibleTextVirtual(virtualNode, { + includeHidden: true +}); +const name = curateText(accText, { + emoji: true, + nonBmp: true, + punctuations: true, + sanitize: true, + lowercase: true +}); if (!name) { return undefined; } @@ -11,10 +26,9 @@ if (!name) { /** * Set `data` and `relatedNodes` for use in `after` fn */ - const afterData = { name, - parsedResource: getParsedResource(node) + parsedResource: getParsedResource(node, 'href') }; this.data(afterData); this.relatedNodes([node]); @@ -22,36 +36,48 @@ this.relatedNodes([node]); return true; /** - * Get accessible name of a given virtual node - * -> excluding unicode - * -> trim whitespace - * -> transformed to lowercase - * @param {Object} vNode virtual node + * Curate a given string + * @method curateText + * @param {String} str given string to curate + * @param {Object} options options to curate + * @property {Boolean} options.emoji remove emoji characters? + * @property {Boolean} options.nonBmp remove nonBmp characters? + * @property {Boolean} options.punctuations remove punctuation characters? + * @property {Boolean} options.sanitize santize given string? + * @property {Boolean} options.lowercase convert given string to lowercase? * @returns {String} */ -function getCuratedAccessibleName(vNode) { - /** - * Note: - * Firefox does not respect map > area as visible, - * even when used by a img[usemap], hence the usage of `includeHidden` flag, - * in the accessible name computation - */ - const accText = text.accessibleTextVirtual(vNode, { includeHidden: true }); - const accTextNoUnicode = text.removeUnicode(accText, { - emoji: true, - nonBmp: true, - punctuations: true - }); - return text.sanitize(accTextNoUnicode).toLowerCase(); +function curateText(str, options) { + const { + emoji = false, + nonBmp = false, + punctuations = false, + sanitize = false, + lowercase = false + } = options; + + let curatedText = text.removeUnicode(str, { emoji, nonBmp, punctuations }); + + if (sanitize) { + curatedText = text.sanitize(curatedText); + } + + if (lowercase) { + curatedText = curatedText.toLowerCase(); + } + + return curatedText; } /** - * Construct a resource object for a given node based uri/href of the node + * Construct a resource object for a given node fromt he attribute provided + * @method getParsedResource * @param {HTMLElement} currentNode node * @returns {Object} */ -function getParsedResource(currentNode) { - if (!currentNode.href) { +function getParsedResource(currentNode, attribute) { + const value = currentNode[attribute]; + if (!value) { return undefined; } @@ -60,56 +86,24 @@ function getParsedResource(currentNode) { if (!['A', 'AREA'].includes(nodeName)) { parser = document.createElement('a'); - parser.href = currentNode.href; + parser.href = value; } const [pathname, filename] = getPathnameAndFilename(parser.pathname); - return { protocol: parser.prootocol, hostname: parser.hostname, port: parser.port, - pathname: removeLeadingAndTrialingSlash(pathname), + pathname: pathname.replace(/^\/|\/$/g, ''), // revmove lead/trial(ing) slashes search: parser.search, hash: getHash(parser.hash), filename }; } -/** - * Remove leading and trailing slashes of a given text (if any) - * @method removeLeadingAndTrialingSlash - * @param {String} str string - * @returns {String} - */ -function removeLeadingAndTrialingSlash(str) { - return str.replace(/^\/|\/$/g, ''); -} - -/** - * Interpret a given hash - * if `hash` - * -> is `hashbang` -or- `hash` is followed by `slash` - * -> it resolves to a different resource - * - * @method getHash - * @param {String} hash hash component of a parsed uri - * @returns {String|undefined} - */ -function getHash(hash) { - if (!hash) { - return undefined; - } - - if (!(hash.includes('#!/') || hash.includes('#/'))) { - return undefined; - } - - return hash; -} - /** * Resolve if a given pathname has filename & resolve the same as parts + * @method getPathnameAndFilename * @param {String} pathname pathname part of a given uri * @returns {Array} */ @@ -133,3 +127,24 @@ function getPathnameAndFilename(pathname) { return [filename]; } + +/** + * Interpret a given hash + * if `hash` + * -> is `hashbang` -or- `hash` is followed by `slash` + * -> it resolves to a different resource + * @method getHash + * @param {String} hash hash component of a parsed uri + * @returns {String|undefined} + */ +function getHash(hash) { + if (!hash) { + return undefined; + } + + if (!(hash.includes('#!/') || hash.includes('#/'))) { + return undefined; + } + + return hash; +} From 004026f3d6b5c92d0cffebef19f59b1dd9bc95f6 Mon Sep 17 00:00:00 2001 From: jkodu Date: Mon, 26 Aug 2019 14:00:26 +0100 Subject: [PATCH 19/53] docs: update function comments --- lib/checks/navigation/identical-links-same-purpose.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index fc40604ee4..dc48a0cba8 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -73,6 +73,7 @@ function curateText(str, options) { * Construct a resource object for a given node fromt he attribute provided * @method getParsedResource * @param {HTMLElement} currentNode node + * @param {String} attribute of the node to be used for resolving resource * @returns {Object} */ function getParsedResource(currentNode, attribute) { From bff93fcbdd82c0a26d15597028f705736e36141f Mon Sep 17 00:00:00 2001 From: jkodu Date: Tue, 27 Aug 2019 18:40:37 +0100 Subject: [PATCH 20/53] update --- lib/checks/navigation/identical-links-same-purpose.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/checks/navigation/identical-links-same-purpose.js b/lib/checks/navigation/identical-links-same-purpose.js index dc48a0cba8..f34b757f39 100644 --- a/lib/checks/navigation/identical-links-same-purpose.js +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -92,7 +92,7 @@ function getParsedResource(currentNode, attribute) { const [pathname, filename] = getPathnameAndFilename(parser.pathname); return { - protocol: parser.prootocol, + protocol: parser.protocol, hostname: parser.hostname, port: parser.port, pathname: pathname.replace(/^\/|\/$/g, ''), // revmove lead/trial(ing) slashes From 59531374318c8711efc6c322080f4108ee39bb1b Mon Sep 17 00:00:00 2001 From: jkodu Date: Tue, 27 Aug 2019 19:46:21 +0100 Subject: [PATCH 21/53] update --- .../identical-links-same-purpose-after.js | 85 +++++++------------ .../identical-links-same-purpose-after.js | 70 +++++---------- .../identical-links-same-purpose.html | 6 +- .../identical-links-same-purpose.json | 20 +---- 4 files changed, 60 insertions(+), 121 deletions(-) diff --git a/lib/checks/navigation/identical-links-same-purpose-after.js b/lib/checks/navigation/identical-links-same-purpose-after.js index ea7d8dcef5..aecdc0c8fa 100644 --- a/lib/checks/navigation/identical-links-same-purpose-after.js +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -10,86 +10,67 @@ if (results.length < 2) { * - get other results with matching accessible name * - check if same purpose is served * - if not change `result` to `undefined` - * - construct a list of unique results to return + * - construct a list of unique results with relatedNodes to return */ const uniqueResults = []; -const getIdenticalNodesWithSameName = identicalNodesMapper(); +const nameMap = {}; for (let index = 0; index < results.length; index++) { const currentResult = results[index]; const { data } = currentResult; const { name, parsedResource } = data; - const identicalNodes = getIdenticalNodesWithSameName(results, name, index); + if (nameMap[name]) { + continue; + } /** - * when identical nodes exists - * -> but do not resolve to same purpose -or- do not have parsedResource - * Flag result as `incomplete` - * -> deduplicate results - * -> and add as relatedNodes + * when identical nodes exists, + * but do not resolve to same purpose -or- do not have parsedResource + * flag result as `incomplete` */ + const identicalNodes = getIdenticalNodesWithSameName(results, name, index); if ( identicalNodes.length && (!parsedResource || !isIdenticalResource(parsedResource, identicalNodes)) ) { - const matchedResult = uniqueResults.find( - result => result.data.name === name - ); - if (matchedResult) { - continue; - } - currentResult.result = undefined; - currentResult.relatedNodes = []; - const relatedNodesOfIdenticalNodes = identicalNodes - .map(node => node.relatedNodes[0]) - .filter(item => !!item); - currentResult.relatedNodes.push(...relatedNodesOfIdenticalNodes); - - uniqueResults.push(currentResult); - continue; } /** - * Pass result + * -> deduplicate results (for both `pass` or `incomplete`) and add `relatedNodes` if any */ currentResult.relatedNodes = []; + currentResult.relatedNodes.push( + ...identicalNodes.map(node => node.relatedNodes[0]) + ); uniqueResults.push(currentResult); } return uniqueResults; /** - * Helper function to cache key value pair of accessible name vs identical results - * @mehod identicalNodesMapper - * @returns {Function} + * Get list of nodes from results which match a given accessible name + * @method getIdenticalNodesWithSameName + * @param {Array} afterResults results passed to the after fn + * @param {String} expectedName accessible name to be matched + * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration + * @returns {Array} */ -function identicalNodesMapper() { - const nameMap = {}; - - /** - * Get list of nodes from results which match a given accessible name - * @param {Array} afterResults results passed to the after fn - * @param {String} expectedName accessible name to be matched - * @param {Number} excludeIndex exclude `index` of result, that should not be taken into consideration - * @returns {Array} - */ - return function getIdenticalNodesWithSameName( - afterResults, - expectedName, - excludeIndex - ) { - if (nameMap[expectedName]) { - return nameMap[expectedName]; - } - const nodes = afterResults.filter(({ data }, index) => { - const { name } = data; - return index !== excludeIndex && name === expectedName; - }); +function getIdenticalNodesWithSameName( + afterResults, + expectedName, + excludeIndex +) { + if (nameMap[expectedName]) { + return nameMap[expectedName]; + } + const nodes = afterResults.filter(({ data }, index) => { + const { name } = data; + return index !== excludeIndex && name === expectedName; + }); - nameMap[expectedName] === nodes; - return nodes; - }; + nameMap[expectedName] = nodes; + return nodes; } /** diff --git a/test/checks/navigation/identical-links-same-purpose-after.js b/test/checks/navigation/identical-links-same-purpose-after.js index 177c784753..e7f549cb36 100644 --- a/test/checks/navigation/identical-links-same-purpose-after.js +++ b/test/checks/navigation/identical-links-same-purpose-after.js @@ -2,124 +2,94 @@ describe('identical-links-same-purpose-after tests', function() { 'use strict'; var fixture = document.getElementById('fixture'); - var queryFixture = axe.testUtils.queryFixture; var check = checks['identical-links-same-purpose']; afterEach(function() { fixture.innerHTML = ''; }); + function assertResult(result, expectedData, expectedRelatedNodes) { + assert.deepEqual(result.data, expectedData); + assert.deepEqual(result.relatedNodes, expectedRelatedNodes); + } + it('sets results of check result to `undefined` if native links do not serve identical purpose', function() { - var nodeOne = queryFixture( - "follow us" - ); var nodeOneData = { data: { name: 'follow us', parsedResource: { hostname: 'facebook.com' } }, - relatedNodes: [nodeOne], + relatedNodes: ['nodeOne'], result: true }; - var nodeTwo = queryFixture( - "follow us" - ); var nodeTwoData = { data: { name: 'follow us', parsedResource: { hostname: 'instagram.com' } }, - relatedNodes: [nodeTwo], + relatedNodes: ['nodeTwo'], result: true }; var checkResults = [nodeOneData, nodeTwoData]; var results = check.after(checkResults); - assert.lengthOf(results, 1); var result = results[0]; - assert.equal(result.data.name, nodeOneData.data.name); - assert.equal( - result.data.parsedResource.hostname, - nodeOneData.data.parsedResource.hostname - ); - assert.equal(result.relatedNodes.length, 1); + assertResult(result, nodeOneData.data, ['nodeTwo']); assert.isUndefined(result.result); }); it('sets results of check result to `true` if native links serve identical purpose', function() { - var nodeOne = queryFixture( - "Axe Core" - ); var nodeOneData = { data: { name: 'Axe Core', parsedResource: { hostname: 'deque.com', pathname: 'axe-core' } }, - relatedNodes: [nodeOne], + relatedNodes: ['nodeOne'], result: true }; - var nodeTwo = queryFixture( - "Axe Core" - ); var nodeTwoData = { data: { - name: 'Axe Core us', + name: 'Axe Core', parsedResource: { hostname: 'deque.com', pathname: 'axe-core' } }, - relatedNodes: [nodeTwo], + relatedNodes: ['nodeTwo'], result: true }; var checkResults = [nodeOneData, nodeTwoData]; var results = check.after(checkResults); - assert.lengthOf(results, 2); - - var result1 = results[0]; - assert.equal(result1.data.name, nodeOneData.data.name); - assert.equal( - result1.data.parsedResource.pathname, - nodeOneData.data.parsedResource.pathname - ); - assert.equal(result1.relatedNodes.length, 0); - assert.isTrue(result1.result); + assert.lengthOf(results, 1); - var result2 = results[1]; - assert.equal(result2.relatedNodes.length, 0); - assert.isTrue(result2.result); + var result = results[0]; + assertResult(result, nodeOneData.data, ['nodeTwo']); + assert.isTrue(result.result); }); it('sets results of check result to `true` if ARIA links have different accessible names', function() { - var nodeOne = queryFixture(''); var nodeOneData = { data: { name: 'earth', parsedResource: {} }, - relatedNodes: [nodeOne], + relatedNodes: ['nodeOne'], result: true }; - var nodeTwo = queryFixture(''); + var nodeTwoData = { data: { name: 'venus', parsedResource: {} }, - relatedNodes: [nodeTwo], + relatedNodes: ['nodeTwo'], result: true }; var checkResults = [nodeOneData, nodeTwoData]; var results = check.after(checkResults); assert.lengthOf(results, 2); - - var result1 = results[0]; - assert.equal(result1.data.name, nodeOneData.data.name); - assert.equal(result1.relatedNodes.length, 0); - - var result2 = results[1]; - assert.equal(result2.data.name, nodeTwoData.data.name); - assert.equal(result2.relatedNodes.length, 0); + assertResult(results[0], nodeOneData.data, []); + assertResult(results[1], nodeTwoData.data, []); }); }); diff --git a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html index b6af3b4581..066f61cd46 100644 --- a/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html @@ -48,13 +48,13 @@ Pass 11 -Pass 11, but different accessible name - @@ -68,7 +68,7 @@ href="foo" /> Date: Tue, 27 Aug 2019 20:15:13 +0100 Subject: [PATCH 22/53] update --- .../identical-links-same-purpose-after.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/checks/navigation/identical-links-same-purpose-after.js b/test/checks/navigation/identical-links-same-purpose-after.js index e7f549cb36..15ff7faac0 100644 --- a/test/checks/navigation/identical-links-same-purpose-after.js +++ b/test/checks/navigation/identical-links-same-purpose-after.js @@ -8,9 +8,15 @@ describe('identical-links-same-purpose-after tests', function() { fixture.innerHTML = ''; }); - function assertResult(result, expectedData, expectedRelatedNodes) { + function assertResult( + result, + expectedData, + expectedRelatedNodes, + expectedResult + ) { assert.deepEqual(result.data, expectedData); assert.deepEqual(result.relatedNodes, expectedRelatedNodes); + assert.equal(result.result, expectedResult); } it('sets results of check result to `undefined` if native links do not serve identical purpose', function() { @@ -36,8 +42,7 @@ describe('identical-links-same-purpose-after tests', function() { assert.lengthOf(results, 1); var result = results[0]; - assertResult(result, nodeOneData.data, ['nodeTwo']); - assert.isUndefined(result.result); + assertResult(result, nodeOneData.data, ['nodeTwo'], undefined); }); it('sets results of check result to `true` if native links serve identical purpose', function() { @@ -64,8 +69,7 @@ describe('identical-links-same-purpose-after tests', function() { assert.lengthOf(results, 1); var result = results[0]; - assertResult(result, nodeOneData.data, ['nodeTwo']); - assert.isTrue(result.result); + assertResult(result, nodeOneData.data, ['nodeTwo'], true); }); it('sets results of check result to `true` if ARIA links have different accessible names', function() { @@ -89,7 +93,7 @@ describe('identical-links-same-purpose-after tests', function() { var checkResults = [nodeOneData, nodeTwoData]; var results = check.after(checkResults); assert.lengthOf(results, 2); - assertResult(results[0], nodeOneData.data, []); - assertResult(results[1], nodeTwoData.data, []); + assertResult(results[0], nodeOneData.data, [], true); + assertResult(results[1], nodeTwoData.data, [], true); }); }); From 48072b3dbdc6284e1f4419ef14279cf70278047d Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 13 Sep 2019 12:24:33 +0100 Subject: [PATCH 23/53] fix: update isVisible to handle AREA element --- lib/commons/dom/is-visible.js | 53 ++++++++++++++++++++++++++++++++-- test/commons/dom/is-visible.js | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/lib/commons/dom/is-visible.js b/lib/commons/dom/is-visible.js index bd23b50eac..8cdc60258e 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -39,6 +39,44 @@ function isClipped(style) { return false; } +/** + * Check `AREA` element is visible + * - validate if it is a child of `map` + * - ensure `map` is referred by `img` using the `usemap` attribute + * @param {Element} areaEl `AREA` element + * @retruns {Boolean} + */ +function isAreaVisible(el) { + const rootEl = dom.getRootNode(el); + /** + * Note: + * verified that `map` element cannot refer to `area` elements across different document trees + * hence the usage of `closest` as against `findUpVirtual` + */ + const mapEl = el.closest('map'); + if (!mapEl) { + return false; + } + + const mapElStyle = window.getComputedStyle(mapEl, null); + if (mapElStyle.getPropertyValue('display') === 'none') { + return false; + } + + const mapElName = mapEl.getAttribute('name'); + if (!mapElName) { + return false; + } + const refs = rootEl.querySelectorAll( + `img[usemap="#${axe.utils.escapeSelector(mapElName)}"]` + ); + if (!refs.length) { + return false; + } + + return true; +} + /** * Determine whether an element is visible * @method isVisible @@ -74,9 +112,13 @@ dom.isVisible = function(el, screenReader, recursed) { } const nodeName = el.nodeName.toUpperCase(); - if ( - style.getPropertyValue('display') === 'none' || + /** + * Note: + * Firefox's user-agent always sets `AREA` element to `display:none` + * hence excluding the edge case, for visibility computation + */ + (nodeName !== 'AREA' && style.getPropertyValue('display') === 'none') || ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(nodeName) || (!screenReader && isClipped(style)) || (!recursed && @@ -89,6 +131,13 @@ dom.isVisible = function(el, screenReader, recursed) { return false; } + /** + * check visibility of `AREA` + */ + if (nodeName === 'AREA') { + return isAreaVisible(el); + } + const parent = el.assignedSlot ? el.assignedSlot : el.parentNode; let isVisible = false; if (parent) { diff --git a/test/commons/dom/is-visible.js b/test/commons/dom/is-visible.js index 346743872c..729a78f213 100644 --- a/test/commons/dom/is-visible.js +++ b/test/commons/dom/is-visible.js @@ -2,6 +2,7 @@ describe('dom.isVisible', function() { 'use strict'; var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; var fixtureSetup = axe.testUtils.fixtureSetup; var isIE11 = axe.testUtils.isIE11; var shadowSupported = axe.testUtils.shadowSupport.v1; @@ -190,6 +191,57 @@ describe('dom.isVisible', function() { assert.isFalse(axe.commons.dom.isVisible(el)); }); + it('returns false for `AREA` without closest `MAP` element', function() { + var vNode = queryFixture( + '' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns false for `AREA` with closest `MAP` with no name attribute', function() { + var vNode = queryFixture( + '' + + '' + + '' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns false for `AREA` with closest `MAP` with name but not referred by an `IMG` usemap attribute', function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns false for `AREA` with `MAP` and used in `IMG` but `MAP` has `display:none`)', function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isFalse(actual); + }); + + it('returns true for `AREA` with `MAP` and used in `IMG`)', function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isTrue(actual); + }); + // IE11 either only supports clip paths defined by url() or not at all, // MDN and caniuse.com give different results... (isIE11 || window.PHANTOMJS ? it.skip : it)( From ac178843668631b0143dde1779407538069be984 Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 13 Sep 2019 15:05:00 +0100 Subject: [PATCH 24/53] fix: move getParsedResource to commons.dom namespace --- lib/commons/dom/get-parsed-resource.js | 100 ++++++++++++++++++++++ test/commons/dom/get-parsed-resource.js | 105 ++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 lib/commons/dom/get-parsed-resource.js create mode 100644 test/commons/dom/get-parsed-resource.js diff --git a/lib/commons/dom/get-parsed-resource.js b/lib/commons/dom/get-parsed-resource.js new file mode 100644 index 0000000000..cb059ba9eb --- /dev/null +++ b/lib/commons/dom/get-parsed-resource.js @@ -0,0 +1,100 @@ +/* global dom */ + +/** + * Parse resource object for a given node from a specified attribute + * @method getParsedResource + * @param {HTMLElement} node given node + * @param {String} attribute attribute of the node from which resource should be parsed + * @returns {Object} + */ +dom.getParsedResource = function getParsedResource(node, attribute) { + const value = node[attribute]; + if (!value) { + return undefined; + } + + const nodeName = node.nodeName.toUpperCase(); + let parser = node; + + /** + * Note: + * The need to create a parser, is to keep this function generic, to be able to parse resource from element like `iframe` with `src` attribute + */ + if (!['A', 'AREA'].includes(nodeName)) { + parser = document.createElement('a'); + parser.href = value; + } + + const { pathname = '', filename = undefined } = getPathnameAndFilename( + parser.pathname + ); + return { + protocol: parser.protocol, + hostname: parser.hostname, + port: parser.port, + pathname: pathname.replace(/\/$/, ''), // remove trialing slashes + search: parser.search, + hash: getHashRoute(parser.hash), + filename + }; +}; + +/** + * Resolve if a given pathname has filename & resolve the same as parts + * @method getPathnameAndFilename + * @param {String} pathname pathname part of a given uri + * @returns {Array} + */ +function getPathnameAndFilename(pathname) { + if (!pathname) { + return; + } + const filename = pathname.split('/').pop(); + if (!filename || filename.indexOf('.') === -1) { + return { pathname }; + } + + const hasIndexInFilename = /index./.test(filename); + if (hasIndexInFilename) { + return { + pathname: pathname.replace(filename, '') + }; + } + + return { + filename + }; +} + +/** + * Interpret a given hash + * if `hash` + * -> is `hashbang` -or- `hash` is followed by `slash` + * -> it resolves to a different resource + * @method getHashRoute + * @param {String} hash hash component of a parsed uri + * @returns {String|undefined} + */ +function getHashRoute(hash) { + if (!hash) { + return undefined; + } + + /** + * Check for any conventionally-formatted hashbang may be present + * eg: `#, #/, #!, #!/` + */ + const hashRegex = /#!?\/?/g; + const hasMatch = hash.match(hashRegex); + if (!hasMatch) { + return undefined; + } + + // do not resolve inline link as hash + const [matchedStr] = hasMatch; + if (matchedStr === '#') { + return undefined; + } + + return hash; +} diff --git a/test/commons/dom/get-parsed-resource.js b/test/commons/dom/get-parsed-resource.js new file mode 100644 index 0000000000..0e433b3650 --- /dev/null +++ b/test/commons/dom/get-parsed-resource.js @@ -0,0 +1,105 @@ +describe('dom.getParsedResource', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('returns undefined when given node does not have specified attribute', function() { + var vNode = queryFixture( + '' + ); + var actual = axe.commons.dom.getParsedResource(vNode.actualNode, 'href'); + assert.isUndefined(actual); + }); + + it('returns undefined when `A` has no `HREF` attribute', function() { + var vNode = queryFixture('Follow us on Instagram'); + var actual = axe.commons.dom.getParsedResource(vNode.actualNode, 'href'); + assert.isUndefined(actual); + }); + + it('returns parsed resource when `A` has empty `HREF`', function() { + var vNode = queryFixture( + 'Follow us on Instagram' + ); + var actual = axe.commons.dom.getParsedResource(vNode.actualNode, 'href'); + assert.isDefined(actual); + assert.isUndefined(actual.hash); + assert.isUndefined(actual.filename); + assert.equal(actual.hostname, 'localhost'); + assert.equal(actual.pathname, '/test/commons'); + }); + + it('returns parsed resource for `A` with `HREF`', function() { + var vNode = queryFixture( + 'follow us on Facebook' + ); + var actual = axe.commons.dom.getParsedResource(vNode.actualNode, 'href'); + assert.isDefined(actual); + assert.equal(actual.protocol, 'https:'); + assert.equal(actual.hostname, 'facebook.com'); + }); + + it('returns parsed resource for `A` with `HREF` which has subdirectory and inline link', function() { + var vNode = queryFixture( + 'Go to Issues' + ); + var actual = axe.commons.dom.getParsedResource(vNode.actualNode, 'href'); + assert.isDefined(actual); + assert.equal(actual.protocol, 'http:'); + assert.equal(actual.hostname, 'mysite.com'); + assert.equal(actual.pathname, '/directory'); + // inline anchor is not interpreted as hash + assert.isUndefined(actual.hash); + }); + + it('returns parsed resource for `A` with `HREF` which has subdirectory and hashbang', function() { + var vNode = queryFixture( + 'See our services' + ); + var actual = axe.commons.dom.getParsedResource(vNode.actualNode, 'href'); + assert.isDefined(actual); + assert.equal(actual.protocol, 'http:'); + assert.equal(actual.hostname, 'mysite.com'); + assert.equal(actual.pathname, '/directory'); + // hashbang is not interpreted as hash + assert.equal(actual.hash, '#!foo'); + }); + + it('returns parsed resource for `A` with `HREF` which has search query', function() { + var vNode = queryFixture( + 'Get list of foo bars' + ); + var actual = axe.commons.dom.getParsedResource(vNode.actualNode, 'href'); + assert.isDefined(actual); + assert.equal(actual.protocol, 'http:'); + assert.equal(actual.hostname, 'mysite.com'); + assert.equal(actual.pathname, '/search'); + assert.equal(actual.search, '?q=foo'); + }); + + it('returns parsed resource for `IFRAME` with `SRC` which has filename', function() { + var vNode = queryFixture( + ' + + + + + + MDN infographic + + + + + + + + + + diff --git a/test/integration/full/identical-links-same-purpose/page.js b/test/integration/full/identical-links-same-purpose/page.js new file mode 100644 index 0000000000..bb6b66c7a3 --- /dev/null +++ b/test/integration/full/identical-links-same-purpose/page.js @@ -0,0 +1,49 @@ +describe('identical-links-same-purpose test', function() { + 'use strict'; + + var config = { + runOnly: { + type: 'rule', + values: ['identical-links-same-purpose'] + } + }; + + before(function(done) { + axe.testUtils.awaitNestedLoad(done); + axe._tree = undefined; + }); + + it('should find no violations given a selector array', function(done) { + axe.run(config, function(err, results) { + assert.isNull(err); + + /** + * assert `passes` + */ + assert.lengthOf(results.passes, 1, 'passes'); + assert.lengthOf(results.passes[0].nodes, 1); + assert.deepEqual(results.passes[0].nodes[0].target, [ + '#pass-outside-frame' + ]); + assert.deepEqual( + results.passes[0].nodes[0].all[0].relatedNodes[0].target, + ['#myframe', '#pass-inside-frame'] + ); + + /** + * assert `incomplete` + */ + assert.lengthOf(results.incomplete, 1, 'incomplete'); + assert.lengthOf(results.incomplete[0].nodes, 1); + assert.deepEqual(results.incomplete[0].nodes[0].target, [ + '#incomplete-outside-frame' + ]); + assert.deepEqual( + results.incomplete[0].nodes[0].all[0].relatedNodes[0].target, + ['#myframe', '#incomplete-inside-frame'] + ); + + done(); + }); + }); +}); From 9a562dcf293fdfdfa60898893c50cb26447d5bc3 Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 13 Sep 2019 16:27:00 +0100 Subject: [PATCH 28/53] test: update isvisible test --- lib/commons/dom/is-visible.js | 9 ++-- test/commons/dom/is-visible.js | 99 +++++++++++++--------------------- 2 files changed, 39 insertions(+), 69 deletions(-) diff --git a/lib/commons/dom/is-visible.js b/lib/commons/dom/is-visible.js index 8cdc60258e..8cc95ad620 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -50,19 +50,16 @@ function isAreaVisible(el) { const rootEl = dom.getRootNode(el); /** * Note: - * verified that `map` element cannot refer to `area` elements across different document trees + * Verified that `map` element cannot refer to `area` elements across different document trees * hence the usage of `closest` as against `findUpVirtual` + * + * Also, verified that `map` element does not get affected by altering `display` property */ const mapEl = el.closest('map'); if (!mapEl) { return false; } - const mapElStyle = window.getComputedStyle(mapEl, null); - if (mapElStyle.getPropertyValue('display') === 'none') { - return false; - } - const mapElName = mapEl.getAttribute('name'); if (!mapElName) { return false; diff --git a/test/commons/dom/is-visible.js b/test/commons/dom/is-visible.js index 829f3dd4b0..e46fc42a93 100644 --- a/test/commons/dom/is-visible.js +++ b/test/commons/dom/is-visible.js @@ -3,7 +3,6 @@ describe('dom.isVisible', function() { var fixture = document.getElementById('fixture'); var queryFixture = axe.testUtils.queryFixture; - var isPhantom = window.PHANTOMJS ? true : false; var fixtureSetup = axe.testUtils.fixtureSetup; var isIE11 = axe.testUtils.isIE11; var shadowSupported = axe.testUtils.shadowSupport.v1; @@ -192,71 +191,45 @@ describe('dom.isVisible', function() { assert.isFalse(axe.commons.dom.isVisible(el)); }); - (isPhantom ? xit : it)( - 'returns false for `AREA` without closest `MAP` element', - function() { - var vNode = queryFixture( - '' - ); - var actual = axe.commons.dom.isVisible(vNode.actualNode); - assert.isFalse(actual); - } - ); - - (isPhantom ? xit : it)( - 'returns false for `AREA` with closest `MAP` with no name attribute', - function() { - var vNode = queryFixture( - '' + - '' + - '' - ); - var actual = axe.commons.dom.isVisible(vNode.actualNode); - assert.isFalse(actual); - } - ); + it('returns false for `AREA` without closest `MAP` element', function() { + var vNode = queryFixture( + '' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isFalse(actual); + }); - (isPhantom ? xit : it)( - 'returns false for `AREA` with closest `MAP` with name but not referred by an `IMG` usemap attribute', - function() { - var vNode = queryFixture( - '' + - '' + - '' + - 'MDN infographic' - ); - var actual = axe.commons.dom.isVisible(vNode.actualNode); - assert.isFalse(actual); - } - ); + it('returns false for `AREA` with closest `MAP` with no name attribute', function() { + var vNode = queryFixture( + '' + + '' + + '' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isFalse(actual); + }); - (isPhantom ? xit : it)( - 'returns false for `AREA` with `MAP` and used in `IMG` but `MAP` has `display:none`)', - function() { - var vNode = queryFixture( - '' + - '' + - '' + - 'MDN infographic' - ); - var actual = axe.commons.dom.isVisible(vNode.actualNode); - assert.isFalse(actual); - } - ); + it('returns false for `AREA` with closest `MAP` with name but not referred by an `IMG` usemap attribute', function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isFalse(actual); + }); - (isPhantom ? xit : it)( - 'returns true for `AREA` with `MAP` and used in `IMG`)', - function() { - var vNode = queryFixture( - '' + - '' + - '' + - 'MDN infographic' - ); - var actual = axe.commons.dom.isVisible(vNode.actualNode); - assert.isTrue(actual); - } - ); + it('returns true for `AREA` with `MAP` and used in `IMG`)', function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = axe.commons.dom.isVisible(vNode.actualNode); + assert.isTrue(actual); + }); // IE11 either only supports clip paths defined by url() or not at all, // MDN and caniuse.com give different results... From ebcfeaf13297074e3694f14aa630a66cc5bfe0d2 Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 13 Sep 2019 20:24:39 +0100 Subject: [PATCH 29/53] test: update rule matches tests --- .../identical-links-same-purpose-matches.js | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index 359f612623..9d8b3fed07 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -24,12 +24,6 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isFalse(actual); }); - it('returns false when native link without href', function() { - var vNode = queryFixture('Book Now'); - var actual = rule.matches(vNode.actualNode, vNode); - assert.isFalse(actual); - }); - it('returns false for native link with a role !== link', function() { var vNode = queryFixture( 'Go to Checkout' @@ -38,7 +32,7 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isFalse(actual); }); - it('returns false when `area[href]` has no parent `map` element', function() { + it('returns false when `area` has no parent `map` element', function() { var vNode = queryFixture( '' ); @@ -46,7 +40,7 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isFalse(actual); }); - it('returns false when `area[href]` has parent `map` that is not referred by `img[usemap]`', function() { + it('returns false when `area` has parent `map` that is not referred by `img[usemap]`', function() { var vNode = queryFixture( '' + '' + @@ -56,15 +50,18 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isFalse(actual); }); - it('returns false when `area[href]` has no href', function() { + it('returns true when native link without href', function() { + var vNode = queryFixture('Book Now'); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isTrue(actual); + }); + + it('returns true when ARIA link without href', function() { var vNode = queryFixture( - '' + - '' + - '' + - 'MDN infographic' + '' ); var actual = rule.matches(vNode.actualNode, vNode); - assert.isFalse(actual); + assert.isTrue(actual); }); it('returns true when native link has an accessible name', function() { @@ -81,7 +78,7 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isTrue(actual); }); - it('returns true when `area[href]` has parent `map` that is referred by `img`', function() { + it('returns true when `area` has parent `map` that is referred by `img`', function() { var vNode = queryFixture( '' + '' + From 0498d1d268345ab7cb48830e97cc12a75d8bb4fa Mon Sep 17 00:00:00 2001 From: jkodu Date: Fri, 13 Sep 2019 21:02:13 +0100 Subject: [PATCH 30/53] test: skip area tests in phantomjs --- .../identical-links-same-purpose.js | 131 ++++++++++-------- .../identical-links-same-purpose-matches.js | 62 +++++---- 2 files changed, 108 insertions(+), 85 deletions(-) diff --git a/test/checks/navigation/identical-links-same-purpose.js b/test/checks/navigation/identical-links-same-purpose.js index a6e3198148..192e94b923 100644 --- a/test/checks/navigation/identical-links-same-purpose.js +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -4,6 +4,7 @@ describe('identical-links-same-purpose tests', function() { var fixture = document.getElementById('fixture'); var shadowSupported = axe.testUtils.shadowSupport.v1; var shadowCheckSetup = axe.testUtils.shadowCheckSetup; + var isPhantom = window.PHANTOMJS ? true : false; var queryFixture = axe.testUtils.queryFixture; var check = checks['identical-links-same-purpose']; var checkContext = axe.testUtils.MockCheckContext(); @@ -122,68 +123,80 @@ describe('identical-links-same-purpose tests', function() { assert.equal(checkContext._data.parsedResource.pathname, '/home'); }); - it('returns undefined for `AREA` without closest `MAP` element', function() { - var vNode = queryFixture( - '' - ); - var actual = check.evaluate.call( - checkContext, - vNode.actualNode, - options, - vNode - ); - assert.isUndefined(actual); - }); + (isPhantom ? xit : it)( + 'returns undefined for `AREA` without closest `MAP` element', + function() { + var vNode = queryFixture( + '' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isUndefined(actual); + } + ); - it('returns undefined for `AREA with closest `MAP` with no name attribute', function() { - var vNode = queryFixture( - '' + - '' + - '' - ); - var actual = check.evaluate.call( - checkContext, - vNode.actualNode, - options, - vNode - ); - assert.isUndefined(actual); - }); + (isPhantom ? xit : it)( + 'returns undefined for `AREA with closest `MAP` with no name attribute', + function() { + var vNode = queryFixture( + '' + + '' + + '' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isUndefined(actual); + } + ); - it('returns undefined for `AREA with closest `MAP` with name but not referred by an `IMG` usemap attribute', function() { - var vNode = queryFixture( - '' + - '' + - '' + - 'MDN infographic' - ); - var actual = check.evaluate.call( - checkContext, - vNode.actualNode, - options, - vNode - ); - assert.isUndefined(actual); - }); + (isPhantom ? xit : it)( + 'returns undefined for `AREA with closest `MAP` with name but not referred by an `IMG` usemap attribute', + function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isUndefined(actual); + } + ); - it('returns true for ARIA links has accessible name (AREA with `MAP` which is used in `IMG`)', function() { - var vNode = queryFixture( - '' + - '' + - '' + - 'MDN infographic' - ); - var actual = check.evaluate.call( - checkContext, - vNode.actualNode, - options, - vNode - ); - assert.isTrue(actual); - assert.hasAllKeys(checkContext._data, ['name', 'parsedResource']); - assert.equal(checkContext._data.name, 'MDN'.toLowerCase()); - assert.isFalse(!!checkContext._data.resource); - }); + (isPhantom ? xit : it)( + 'returns true for ARIA links has accessible name (AREA with `MAP` which is used in `IMG`)', + function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['name', 'parsedResource']); + assert.equal(checkContext._data.name, 'MDN'.toLowerCase()); + assert.isFalse(!!checkContext._data.resource); + } + ); it('returns true for native links with `href` and accessible name (that also has emoji, nonBmp and punctuation characters)', function() { var vNode = queryFixture( diff --git a/test/rule-matches/identical-links-same-purpose-matches.js b/test/rule-matches/identical-links-same-purpose-matches.js index 9d8b3fed07..a326790616 100644 --- a/test/rule-matches/identical-links-same-purpose-matches.js +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -3,6 +3,7 @@ describe('identical-links-same-purpose-matches tests', function() { var fixture = document.getElementById('fixture'); var queryFixture = axe.testUtils.queryFixture; + var isPhantom = window.PHANTOMJS ? true : false; var rule = axe._audit.rules.find(function(rule) { return rule.id === 'identical-links-same-purpose'; }); @@ -32,23 +33,29 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isFalse(actual); }); - it('returns false when `area` has no parent `map` element', function() { - var vNode = queryFixture( - '' - ); - var actual = rule.matches(vNode.actualNode, vNode); - assert.isFalse(actual); - }); + (isPhantom ? xit : it)( + 'returns false when `area` has no parent `map` element', + function() { + var vNode = queryFixture( + '' + ); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isFalse(actual); + } + ); - it('returns false when `area` has parent `map` that is not referred by `img[usemap]`', function() { - var vNode = queryFixture( - '' + - '' + - '' - ); - var actual = rule.matches(vNode.actualNode, vNode); - assert.isFalse(actual); - }); + (isPhantom ? xit : it)( + 'returns false when `area` has parent `map` that is not referred by `img[usemap]`', + function() { + var vNode = queryFixture( + '' + + '' + + '' + ); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isFalse(actual); + } + ); it('returns true when native link without href', function() { var vNode = queryFixture('Book Now'); @@ -78,14 +85,17 @@ describe('identical-links-same-purpose-matches tests', function() { assert.isTrue(actual); }); - it('returns true when `area` has parent `map` that is referred by `img`', function() { - var vNode = queryFixture( - '' + - '' + - '' + - 'MDN infographic' - ); - var actual = rule.matches(vNode.actualNode, vNode); - assert.isTrue(actual); - }); + (isPhantom ? xit : it)( + 'returns true when `area` has parent `map` that is referred by `img`', + function() { + var vNode = queryFixture( + '' + + '' + + '' + + 'MDN infographic' + ); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isTrue(actual); + } + ); }); From be24a4e84f24ac863a88f3daf6c6e75d6328a3df Mon Sep 17 00:00:00 2001 From: jkodu Date: Mon, 16 Sep 2019 10:26:50 +0100 Subject: [PATCH 31/53] fix: tests --- lib/commons/dom/is-visible.js | 9 ++++----- test/commons/dom/get-parsed-resource.js | 12 ++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/commons/dom/is-visible.js b/lib/commons/dom/is-visible.js index 8cc95ad620..edb07bf75b 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -50,12 +50,11 @@ function isAreaVisible(el) { const rootEl = dom.getRootNode(el); /** * Note: - * Verified that `map` element cannot refer to `area` elements across different document trees - * hence the usage of `closest` as against `findUpVirtual` - * - * Also, verified that `map` element does not get affected by altering `display` property + * - Verified that `map` element cannot refer to `area` elements across different document trees + * - Verified that `map` element does not get affected by altering `display` property + * - using `.cloesest` fails in `phantomjs`, hence using `dom.findUp` */ - const mapEl = el.closest('map'); + const mapEl = dom.findUp(el, 'map'); if (!mapEl) { return false; } diff --git a/test/commons/dom/get-parsed-resource.js b/test/commons/dom/get-parsed-resource.js index 0e433b3650..bdc0389786 100644 --- a/test/commons/dom/get-parsed-resource.js +++ b/test/commons/dom/get-parsed-resource.js @@ -82,20 +82,20 @@ describe('dom.getParsedResource', function() { assert.equal(actual.search, '?q=foo'); }); - it('returns parsed resource for `IFRAME` with `SRC` which has filename', function() { + it('returns parsed resource for `A` with `HREF` which has filename', function() { var vNode = queryFixture( - '