diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 289dff15c5..a82d78ac27 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -42,6 +42,7 @@ | html-has-lang | Ensures every HTML document has a lang attribute | Serious | cat.language, wcag2a, wcag311 | true | true | false | | html-lang-valid | Ensures the lang attribute of the <html> element has a valid value | Serious | cat.language, wcag2a, wcag311 | true | true | false | | 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 | true | false | +| identical-links-same-purpose | Ensure that links with the same accessible name serve a similar purpose | Minor | wcag2aaa, wcag249, best-practice | true | false | 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 | true | false | | image-redundant-alt | Ensure image alternative is not repeated as text | Minor | cat.text-alternatives, best-practice | true | true | false | | input-button-name | Ensures input buttons have discernible text | Critical | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a | true | true | false | 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..2212c38ca8 --- /dev/null +++ b/lib/checks/navigation/identical-links-same-purpose-after.js @@ -0,0 +1,100 @@ +/** + * Skip, as no results to curate + */ +if (results.length < 2) { + return results; +} + +/** + * Filter results for which `result` is undefined & thus `data`, `relatedNodes` are undefined + */ +const incompleteResults = results.filter(({ result }) => result !== undefined); + +/** + * for each result + * - 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 with relatedNodes to return + */ +const uniqueResults = []; +const nameMap = {}; + +for (let index = 0; index < incompleteResults.length; index++) { + const currentResult = incompleteResults[index]; + + const { name, urlProps } = currentResult.data; + /** + * This is to avoid duplications in the `nodeMap` + */ + if (nameMap[name]) { + continue; + } + + const sameNameResults = incompleteResults.filter( + ({ data }, resultNum) => data.name === name && resultNum !== index + ); + const isSameUrl = sameNameResults.every(({ data }) => + isIdenticalObject(data.urlProps, urlProps) + ); + + /** + * when identical nodes exists but do not resolve to same url, flag result as `incomplete` + */ + if (sameNameResults.length && !isSameUrl) { + currentResult.result = undefined; + } + + /** + * -> deduplicate results (for both `pass` or `incomplete`) and add `relatedNodes` if any + */ + currentResult.relatedNodes = []; + currentResult.relatedNodes.push( + ...sameNameResults.map(node => node.relatedNodes[0]) + ); + + /** + * Update `nodeMap` with `sameNameResults` + */ + nameMap[name] = sameNameResults; + + uniqueResults.push(currentResult); +} + +return uniqueResults; + +/** + * Check if two given objects are the same (Note: this fn is not extensive in terms of depth equality) + * @param {Object} a object a, to compare + * @param {*} b object b, to compare + * @returns {Boolean} + */ +function isIdenticalObject(a, b) { + if (!a || !b) { + return false; + } + + const aProps = Object.getOwnPropertyNames(a); + const bProps = Object.getOwnPropertyNames(b); + + if (aProps.length !== bProps.length) { + return false; + } + + const result = aProps.every(propName => { + const aValue = a[propName]; + const bValue = b[propName]; + + if (typeof aValue !== typeof bValue) { + return false; + } + + if (typeof aValue === `object` || typeof bValue === `object`) { + return isIdenticalObject(aValue, bValue); + } + + return aValue === bValue; + }); + + return result; +} 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..d8de030364 --- /dev/null +++ b/lib/checks/navigation/identical-links-same-purpose.js @@ -0,0 +1,31 @@ +/** + * Note: `identical-links-same-purpose-after` fn, helps reconcile the results + */ +const { dom, text } = axe.commons; + +const accText = text.accessibleTextVirtual(virtualNode); +const name = text + .sanitize( + text.removeUnicode(accText, { + emoji: true, + nonBmp: true, + punctuations: true + }) + ) + .toLowerCase(); + +if (!name) { + return undefined; +} + +/** + * Set `data` and `relatedNodes` for use in `after` fn + */ +const afterData = { + name, + urlProps: dom.urlPropsFromAttribute(node, 'href') +}; +this.data(afterData); +this.relatedNodes([node]); + +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..305bf45f4f --- /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", + "after": "identical-links-same-purpose-after.js", + "metadata": { + "impact": "minor", + "messages": { + "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/commons/dom/is-visible.js b/lib/commons/dom/is-visible.js index bd23b50eac..3ca8b07b41 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -39,6 +39,50 @@ 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, screenReader, recursed) { + /** + * Note: + * - 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 + */ + const mapEl = dom.findUp(el, 'map'); + if (!mapEl) { + return false; + } + + const mapElName = mapEl.getAttribute('name'); + if (!mapElName) { + return false; + } + + /** + * `map` element has to be in light DOM + */ + const mapElRootNode = dom.getRootNode(el); + if (!mapElRootNode || mapElRootNode.nodeType !== 9) { + return false; + } + + const refs = axe.utils.querySelectorAll( + axe._tree, + `img[usemap="#${axe.utils.escapeSelector(mapElName)}"]` + ); + if (!refs || !refs.length) { + return false; + } + + return refs.some(({ actualNode }) => + dom.isVisible(actualNode, screenReader, recursed) + ); +} + /** * Determine whether an element is visible * @method isVisible @@ -74,9 +118,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 +137,13 @@ dom.isVisible = function(el, screenReader, recursed) { return false; } + /** + * check visibility of `AREA` + */ + if (nodeName === 'AREA') { + return isAreaVisible(el, screenReader, recursed); + } + const parent = el.assignedSlot ? el.assignedSlot : el.parentNode; let isVisible = false; if (parent) { diff --git a/lib/commons/dom/url-props-from-attribute.js b/lib/commons/dom/url-props-from-attribute.js new file mode 100644 index 0000000000..c4aaea5093 --- /dev/null +++ b/lib/commons/dom/url-props-from-attribute.js @@ -0,0 +1,143 @@ +/* global dom */ + +/** + * Parse resource object for a given node from a specified attribute + * @method urlPropsFromAttribute + * @param {HTMLElement} node given node + * @param {String} attribute attribute of the node from which resource should be parsed + * @returns {Object} + */ +dom.urlPropsFromAttribute = function urlPropsFromAttribute(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; + } + + /** + * Curate `https` and `ftps` to `http` and `ftp` as they will resolve to same resource + */ + const protocol = [`https:`, `ftps:`].includes(parser.protocol) + ? parser.protocol.replace(/s:$/, ':') + : parser.protocol; + + const { pathname, filename } = getPathnameOrFilename(parser.pathname); + + return { + protocol, + hostname: parser.hostname, + port: getPort(parser.port), + pathname: /\/$/.test(pathname) ? pathname : `${pathname}/`, + search: getSearchPairs(parser.search), + hash: getHashRoute(parser.hash), + filename + }; +}; + +/** + * Resolve given port excluding default port(s) + * @param {String} port port + * @returns {String} + */ +function getPort(port) { + const excludePorts = [ + `443`, // default `https` port + `80` + ]; + return !excludePorts.includes(port) ? port : ``; +} + +/** + * Resolve if a given pathname has filename & resolve the same as parts + * @method getPathnameOrFilename + * @param {String} pathname pathname part of a given uri + * @returns {Array} + */ +function getPathnameOrFilename(pathname) { + const filename = pathname.split('/').pop(); + if (!filename || filename.indexOf('.') === -1) { + return { + pathname, + filename: `` + }; + } + + return { + // remove `filename` from `pathname` + pathname: pathname.replace(filename, ''), + + // ignore filename when index.* + filename: /index./.test(filename) ? `` : filename + }; +} + +/** + * Parse a given query string to key/value pairs sorted alphabetically + * @param {String} searchStr search string + * @returns {Object} + */ +function getSearchPairs(searchStr) { + const query = {}; + + if (!searchStr || !searchStr.length) { + return query; + } + + // `substring` to remove `?` at the beginning of search string + const pairs = searchStr.substring(1).split(`&`); + if (!pairs || !pairs.length) { + return query; + } + + for (let index = 0; index < pairs.length; index++) { + const pair = pairs[index]; + const [key, value = ''] = pair.split(`=`); + query[decodeURIComponent(key)] = decodeURIComponent(value); + } + + return query; +} + +/** + * 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} + */ +function getHashRoute(hash) { + if (!hash) { + return ``; + } + + /** + * Check for any conventionally-formatted hashbang that may be present + * eg: `#, #/, #!, #!/` + */ + const hashRegex = /#!?\/?/g; + const hasMatch = hash.match(hashRegex); + if (!hasMatch) { + return ``; + } + + // do not resolve inline link as hash + const [matchedStr] = hasMatch; + if (matchedStr === '#') { + return ``; + } + + return hash; +} 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..a87c360e4b --- /dev/null +++ b/lib/rules/identical-links-same-purpose-matches.js @@ -0,0 +1,13 @@ +const { aria, text } = axe.commons; + +const hasAccName = !!text.accessibleTextVirtual(virtualNode); +if (!hasAccName) { + return false; +} + +const role = aria.getRole(node); +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 new file mode 100644 index 0000000000..37f314b9a5 --- /dev/null +++ b/lib/rules/identical-links-same-purpose.json @@ -0,0 +1,14 @@ +{ + "id": "identical-links-same-purpose", + "selector": "a[href], area[href], [role=\"link\"]", + "excludeHidden": false, + "matches": "identical-links-same-purpose-matches.js", + "tags": ["wcag2aaa", "wcag249", "best-practice"], + "metadata": { + "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": [], + "none": [] +} 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..34ac15912e --- /dev/null +++ b/test/checks/navigation/identical-links-same-purpose-after.js @@ -0,0 +1,179 @@ +describe('identical-links-same-purpose-after tests', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var check = checks['identical-links-same-purpose']; + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('returns results by clearing relatedNodes after ignoring nodes which has no data (or result is undefined)', function() { + var nodeOneData = { + data: null, + relatedNodes: ['nodeOne'], + result: undefined + }; + var nodeTwoData = { + data: { + name: 'read more', + urlProps: { hostname: 'abc.com' } + }, + relatedNodes: ['nodeTwo'], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + + var results = check.after(checkResults); + assert.lengthOf(results, 1); + + var result = results[0]; + assert.deepEqual(result.data, nodeTwoData.data); + assert.deepEqual(result.relatedNodes, []); + assert.equal(result.result, true); + }); + + it('sets results of check result to `undefined` one of the native links do not have `urlProps` (and therefore removed as relatedNode)', function() { + var nodeOneData = { + data: { + name: 'read more', + urlProps: undefined + }, + relatedNodes: ['nodeOne'], + result: true + }; + var nodeTwoData = { + data: { + name: 'read more', + urlProps: { hostname: 'abc.com' } + }, + relatedNodes: ['nodeTwo'], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + + var results = check.after(checkResults); + assert.lengthOf(results, 1); + + var result = results[0]; + assert.deepEqual(result.data, nodeOneData.data); + assert.deepEqual(result.relatedNodes, ['nodeTwo']); + assert.equal(result.result, undefined); + }); + + it('sets results of check result to `undefined` if native links do not have same `urlProps` (values are different)', function() { + var nodeOneData = { + data: { + name: 'follow us', + urlProps: { hostname: 'facebook.com' } + }, + relatedNodes: ['nodeOne'], + result: true + }; + var nodeTwoData = { + data: { + name: 'follow us', + urlProps: { hostname: 'instagram.com' } + }, + relatedNodes: ['nodeTwo'], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + + var results = check.after(checkResults); + assert.lengthOf(results, 1); + + var result = results[0]; + assert.deepEqual(result.data, nodeOneData.data); + assert.deepEqual(result.relatedNodes, ['nodeTwo']); + assert.equal(result.result, undefined); + }); + + it('sets results of check result to `undefined` if native links do not have same `urlProps` (keys are different)', function() { + var nodeOneData = { + data: { + name: 'follow us', + urlProps: { abc: 'abc.com' } + }, + relatedNodes: ['nodeOne'], + result: true + }; + var nodeTwoData = { + data: { + name: 'follow us', + urlProps: { xyz: 'abc.com' } + }, + relatedNodes: ['nodeTwo'], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + + var results = check.after(checkResults); + assert.lengthOf(results, 1); + + var result = results[0]; + assert.deepEqual(result.data, nodeOneData.data); + assert.deepEqual(result.relatedNodes, ['nodeTwo']); + assert.equal(result.result, undefined); + }); + + it('sets results of check result to `true` if native links serve identical purpose', function() { + var nodeOneData = { + data: { + name: 'Axe Core', + urlProps: { hostname: 'deque.com', pathname: 'axe-core' } + }, + relatedNodes: ['nodeOne'], + result: true + }; + var nodeTwoData = { + data: { + name: 'Axe Core', + urlProps: { hostname: 'deque.com', pathname: 'axe-core' } + }, + relatedNodes: ['nodeTwo'], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + + var results = check.after(checkResults); + + assert.lengthOf(results, 1); + + var result = results[0]; + assert.deepEqual(result.data, nodeOneData.data); + assert.deepEqual(result.relatedNodes, ['nodeTwo']); + assert.equal(result.result, true); + }); + + it('sets results of check result to `true` if ARIA links have different accessible names', function() { + var nodeOneData = { + data: { + name: 'earth', + urlProps: {} + }, + relatedNodes: ['nodeOne'], + result: true + }; + + var nodeTwoData = { + data: { + name: 'venus', + urlProps: {} + }, + relatedNodes: ['nodeTwo'], + result: true + }; + var checkResults = [nodeOneData, nodeTwoData]; + var results = check.after(checkResults); + assert.lengthOf(results, 2); + + assert.deepEqual(results[0].data, nodeOneData.data); + assert.deepEqual(results[0].relatedNodes, []); + assert.equal(results[0].result, true); + + assert.deepEqual(results[1].data, nodeTwoData.data); + assert.deepEqual(results[1].relatedNodes, []); + assert.equal(results[1].result, true); + }); +}); 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..d82d0b5c73 --- /dev/null +++ b/test/checks/navigation/identical-links-same-purpose.js @@ -0,0 +1,106 @@ +describe('identical-links-same-purpose tests', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; + var check = checks['identical-links-same-purpose']; + var checkContext = axe.testUtils.MockCheckContext(); + var options = {}; + + afterEach(function() { + fixture.innerHTML = ''; + checkContext.reset(); + axe._tree = undefined; + }); + + it('returns undefined for native link with `href` but 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 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 ARIA link has only any combination of unicode (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); + }); + + 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', 'urlProps']); + assert.equal(checkContext._data.name, 'Pass 1'.toLowerCase()); + assert.equal(checkContext._data.urlProps.hash, '#/foo'); + assert.equal(checkContext._data.urlProps.pathname, '/home/'); + }); + + 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', 'urlProps']); + 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.' + ); + var actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isTrue(actual); + assert.hasAllKeys(checkContext._data, ['name', 'urlProps']); + assert.equal( + checkContext._data.name, + 'The is orange the is white'.toLowerCase() + ); + assert.equal(checkContext._data.urlProps.filename, 'foo.html'); + }); +}); diff --git a/test/commons/dom/is-visible.js b/test/commons/dom/is-visible.js index d28f70643b..124cd12203 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; @@ -12,7 +13,9 @@ describe('dom.isVisible', function() { afterEach(function() { document.getElementById('fixture').innerHTML = ''; + axe._tree = undefined; }); + describe('default usage', function() { // Firefox returns `null` if accessed inside a hidden iframe it('should return false if computedStyle return null for whatever reason', function() { @@ -190,6 +193,75 @@ 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); + }); + + (shadowSupported ? it : xit)( + 'returns false for `AREA` element that is inside shadowDOM', + function() { + fixture.innerHTML = '
'; + var container = fixture.querySelector('#container'); + var shadow = container.attachShadow({ mode: 'open' }); + shadow.innerHTML = + '' + + '' + + ''; + axe.testUtils.flatTreeSetup(fixture); + + var target = shadow.querySelector('#target'); + var actual = axe.commons.dom.isVisible(target); + 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` which is not visible', 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` which is visible', 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 ? it.skip : it)( diff --git a/test/commons/dom/url-props-from-attribute.js b/test/commons/dom/url-props-from-attribute.js new file mode 100644 index 0000000000..c5369b9ec4 --- /dev/null +++ b/test/commons/dom/url-props-from-attribute.js @@ -0,0 +1,223 @@ +describe('dom.urlPropsFromAttribute', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; + var isIE11 = axe.testUtils.isIE11; + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('returns undefined when given node does not have specified attribute', function() { + var vNode = queryFixture( + '' + ); + var actual = axe.commons.dom.urlPropsFromAttribute( + 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.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.isUndefined(actual); + }); + + it('returns URL properties when `A` with `HREF` (has port)', function() { + var vNode = queryFixture( + 'Follow us on Instagram' + ); + var expected = { + filename: 'playground.html', + hash: '', + hostname: 'localhost', + pathname: '/test/', + port: '9876', + protocol: 'http:', + search: {} + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); + + it('returns URL properties for `A` with `HREF` (having HTTPS protocol)', function() { + var vNode = queryFixture( + 'follow us on Facebook' + ); + var expected = { + filename: '', + hash: '', + hostname: 'facebook.com', + pathname: '/', + port: '', + protocol: 'http:', + search: {} + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); + + (isIE11 ? it.skip : it)( + 'returns URL properties for `A` with `HREF` (having FTP protocol)', + function() { + var vNode = queryFixture( + 'Navigate to My Website' + ); + var expected = { + filename: '', + hash: '', + hostname: 'mywebsite.org', + pathname: '/', + port: '', + protocol: 'ftp:', + search: {} + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + } + ); + + it('returns URL properties for `A` with `HREF` which has subdirectory and inline link', function() { + var vNode = queryFixture( + 'Go to Issues' + ); + var expected = { + filename: '', + hash: '', + hostname: 'mysite.com', + pathname: '/directory/', + port: '', + protocol: 'http:', + search: {} + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); + + it('returns URL properties for `A` with `HREF` which has subdirectory and hashbang', function() { + var vNode = queryFixture( + 'See our services' + ); + var expected = { + filename: '', + hash: '#!foo', + hostname: 'mysite.com', + pathname: '/directory/', + port: '', + protocol: 'http:', + search: {} + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); + + it('returns URL properties for `A` with `HREF` which has search query', function() { + var vNode = queryFixture( + 'Get list of foo bars' + ); + var expected = { + filename: '', + hash: '', + hostname: 'mysite.com', + pathname: '/search/', + port: '', + protocol: 'http:', + search: { + q: 'foo' + } + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); + + it('returns URL properties for `A` with `HREF` which has multiple search query parameters', function() { + var vNode = queryFixture( + 'Get list of foo bars' + ); + var expected = { + filename: '', + hash: '', + hostname: 'mysite.com', + pathname: '/search/', + port: '', + protocol: 'http:', + search: { + a: '123', + z: 'XYZ', + name: 'Axe', + branch: '', + values: '[1,2,3]', + version: '1.2.3' + } + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); + + it('returns URL properties for `A` with `HREF` which has filename', function() { + var vNode = queryFixture( + 'Book tour' + ); + var expected = { + filename: 'calendar.html', + hash: '', + hostname: 'mysite.com', + pathname: '/directory/widgets/', + port: '', + protocol: 'http:', + search: {} + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); + + it('returns URL properties for `A` with `HREF` which has filename as `index` (ignores index.*)', function() { + var vNode = queryFixture( + 'Book tour' + ); + var expected = { + filename: '', + hash: '', + hostname: 'mysite.com', + pathname: '/directory/', + port: '', + protocol: 'http:', + search: {} + }; + var actual = axe.commons.dom.urlPropsFromAttribute( + vNode.actualNode, + 'href' + ); + assert.deepEqual(actual, expected); + }); +}); diff --git a/test/integration/full/identical-links-same-purpose/frames/level1.html b/test/integration/full/identical-links-same-purpose/frames/level1.html new file mode 100644 index 0000000000..fb8ea0d80e --- /dev/null +++ b/test/integration/full/identical-links-same-purpose/frames/level1.html @@ -0,0 +1,29 @@ + + + + + + + + + + Pass 1 + + + + + + MDN infographic + + diff --git a/test/integration/full/identical-links-same-purpose/page.html b/test/integration/full/identical-links-same-purpose/page.html new file mode 100644 index 0000000000..eab33bd782 --- /dev/null +++ b/test/integration/full/identical-links-same-purpose/page.html @@ -0,0 +1,53 @@ + + + + identical-links-same-purpose test + + + + + + + + + +
+ + Pass 1 + + + + + + + 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(); + }); + }); +}); 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..a5495a0037 --- /dev/null +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.html @@ -0,0 +1,237 @@ + + +Pass 1 +Pass 1 + + +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 + + + +Pass 15 +Pass 15 + + + + +Incomplete 1 +Incomplete 1 + + +Incomplete 2 +Incomplete 2 + + +Incomplete 3 +Incomplete 3 + + + +
Incomplete 4
+ + + +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 + + +Incomplete 14 +Incomplete 14 +Incomplete 14 + + + + + + + + + + + + + + + + +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 new file mode 100644 index 0000000000..0ead04561f --- /dev/null +++ b/test/integration/rules/identical-links-same-purpose/identical-links-same-purpose.json @@ -0,0 +1,40 @@ +{ + "description": "identical-links-same-purpose tests", + "rule": "identical-links-same-purpose", + "passes": [ + ["#pass1"], + ["#pass2"], + ["#pass3"], + ["#pass4"], + ["#pass5"], + ["#pass6"], + ["#pass7"], + ["#pass8"], + ["#pass9"], + ["#pass10"], + ["#pass11"], + ["#pass11-identical-resource-but-different-name"], + ["#pass12"], + ["#pass12-identical-resource-but-different-name"], + ["#pass13"], + ["#pass13-identical-resource-but-different-name"], + ["#pass14"], + ["#pass15"] + ], + "incomplete": [ + ["#incomplete1"], + ["#incomplete2"], + ["#incomplete3"], + ["#incomplete4"], + ["#incomplete5"], + ["#incomplete6"], + ["#incomplete7"], + ["#incomplete8"], + ["#incomplete9"], + ["#incomplete10"], + ["#incomplete11"], + ["#incomplete12"], + ["#incomplete13"], + ["#incomplete14"] + ] +} 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..a326790616 --- /dev/null +++ b/test/rule-matches/identical-links-same-purpose-matches.js @@ -0,0 +1,101 @@ +describe('identical-links-same-purpose-matches tests', function() { + 'use strict'; + + 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'; + }); + + afterEach(function() { + fixture.innerHTML = ''; + axe._tree = undefined; + }); + + 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 ARIA link without accessible name', function() { + var vNode = queryFixture(''); + 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' + ); + 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); + } + ); + + (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'); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isTrue(actual); + }); + + it('returns true when ARIA link without href', function() { + var vNode = queryFixture( + '' + ); + var actual = rule.matches(vNode.actualNode, vNode); + assert.isTrue(actual); + }); + + 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 for ARIA link has an accessible name', function() { + var vNode = queryFixture('Book Tour'); + 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); + } + ); +});