From ee49d3e83e8c77159d22b475c7d6d801d921b114 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Thu, 20 Jan 2022 08:33:56 -0700 Subject: [PATCH] fix(skip-link): work with absolute and relative paths (#2875) * fix(skip-link): work with absoulte and relative paths * revert playground * fix ie11 * fix ie11 again * Update lib/commons/dom/is-skip-link.js Co-authored-by: Wilco Fiers * finalize * fix * fix ie11 (always) * typo * fix jsdom test * Update lib/commons/dom/is-current-page-link.js Co-authored-by: Wilco Fiers * fix issue with usemap * fix ie11 Co-authored-by: Wilco Fiers --- lib/commons/dom/get-element-by-reference.js | 12 ++- lib/commons/dom/index.js | 1 + lib/commons/dom/is-current-page-link.js | 66 ++++++++++++ lib/commons/dom/is-skip-link.js | 39 ++++--- test/commons/dom/get-element-by-reference.js | 39 +++++++ test/commons/dom/is-current-page-link.js | 66 ++++++++++++ test/commons/dom/is-skip-link.js | 56 ++++++++++ test/node/jsdom.js | 102 +++++++++++++++++++ 8 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 lib/commons/dom/is-current-page-link.js create mode 100644 test/commons/dom/is-current-page-link.js diff --git a/lib/commons/dom/get-element-by-reference.js b/lib/commons/dom/get-element-by-reference.js index 381d438dfd..dfd2c7f15c 100644 --- a/lib/commons/dom/get-element-by-reference.js +++ b/lib/commons/dom/get-element-by-reference.js @@ -1,3 +1,5 @@ +import isCurrentPageLink from './is-current-page-link'; + /** * Returns a reference to the element matching the attr URL fragment value * @method getElementByReference @@ -13,10 +15,12 @@ function getElementByReference(node, attr) { return null; } - if (fragment.charAt(0) === '#') { - fragment = decodeURIComponent(fragment.substring(1)); - } else if (fragment.substr(0, 2) === '/#') { - fragment = decodeURIComponent(fragment.substring(2)); + if (attr === 'href' && !isCurrentPageLink(node)) { + return null; + } + + if (fragment.indexOf('#') !== -1) { + fragment = decodeURIComponent(fragment.substr(fragment.indexOf('#') + 1)); } let candidate = document.getElementById(fragment); diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 025d2d0a67..5acd13c076 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -19,6 +19,7 @@ export { default as hasContentVirtual } from './has-content-virtual'; export { default as hasContent } from './has-content'; export { default as idrefs } from './idrefs'; export { default as insertedIntoFocusOrder } from './inserted-into-focus-order'; +export { default as isCurrentPageLink } from './is-current-page-link'; export { default as isFocusable } from './is-focusable'; export { default as isHiddenWithCSS } from './is-hidden-with-css'; export { default as isHTML5 } from './is-html5'; diff --git a/lib/commons/dom/is-current-page-link.js b/lib/commons/dom/is-current-page-link.js new file mode 100644 index 0000000000..91c80708ee --- /dev/null +++ b/lib/commons/dom/is-current-page-link.js @@ -0,0 +1,66 @@ +// angular skip links start with /# +const angularSkipLinkRegex = /^\/\#/; + +// angular router link uses #! or #/ +const angularRouterLinkRegex = /^#[!/]/; + +/** + * Determine if an anchor elements href attribute references the current page. + * @method isCurrentPageLink + * @memberof axe.commons.dom + * @param {HTMLAnchorElement} anchor + * @return {Boolean|null} + */ +export default function isCurrentPageLink(anchor) { + const href = anchor.getAttribute('href'); + if (!href || href === '#') { + return false; + } + + if (angularSkipLinkRegex.test(href)) { + return true; + } + + const { hash, protocol, hostname, port, pathname } = anchor; + if (angularRouterLinkRegex.test(hash)) { + return false; + } + + if (href.charAt(0) === '#') { + return true; + } + + // jsdom can have window.location.origin set to "null" (the string) + // if the url option is not set when parsing the dom string + if ( + typeof window.location?.origin !== 'string' || + window.location.origin.indexOf('://') === -1 + ) { + return null; + } + + // ie11 does not support window.origin + const currentPageUrl = window.location.origin + window.location.pathname; + + // ie11 does not have anchor.origin so we need to construct + // it ourselves + // also ie11 has empty protocol, hostname, and port when the + // link is relative, so use window.location.origin in these cases + let url; + if (!hostname) { + url = window.location.origin; + } else { + url = `${protocol}//${hostname}${port ? `:${port}` : ''}`; + } + + // ie11 has empty pathname if link is just a hash, so use + // window.location.pathname in these cases + if (!pathname) { + url += window.location.pathname; + } else { + // ie11 pathname does not start with / but chrome and firefox do + url += (pathname[0] !== '/' ? '/' : '') + pathname; + } + + return url === currentPageUrl; +} diff --git a/lib/commons/dom/is-skip-link.js b/lib/commons/dom/is-skip-link.js index 236b45155a..2ec3139e08 100644 --- a/lib/commons/dom/is-skip-link.js +++ b/lib/commons/dom/is-skip-link.js @@ -1,19 +1,23 @@ import cache from '../../core/base/cache'; import { querySelectorAll } from '../../core/utils'; - -// test for hrefs that start with # or /# (for angular) -const isInternalLinkRegex = /^\/?#[^/!]/; +import isCurrentPageLink from './is-current-page-link'; /** - * Determines if element is a skip link + * Determines if element is a skip link. + * + * Define a skip link as any anchor element whose resolved href + * resolves to the current page and uses a fragment identifier (#) + * and which precedes the first anchor element whose resolved href + * does not resolve to the current page or that doesn't use a + * fragment identifier. * @method isSkipLink * @memberof axe.commons.dom * @instance * @param {Element} element * @return {Boolean} */ -function isSkipLink(element) { - if (!isInternalLinkRegex.test(element.getAttribute('href'))) { +export default function isSkipLink(element) { + if (!element.href) { return false; } @@ -21,14 +25,19 @@ function isSkipLink(element) { if (typeof cache.get('firstPageLink') !== 'undefined') { firstPageLink = cache.get('firstPageLink'); } else { - // define a skip link as any anchor element whose href starts with `#...` - // and which precedes the first anchor element whose href doesn't start - // with `#...` (that is, a link to a page) - firstPageLink = querySelectorAll( - // TODO: es-module-_tree - axe._tree, - 'a:not([href^="#"]):not([href^="/#"]):not([href^="javascript"])' - )[0]; + // jsdom can have window.location.origin set to null + if (!window.location.origin) { + firstPageLink = querySelectorAll( + // TODO: es-module-_tree + axe._tree, + 'a:not([href^="#"]):not([href^="/#"]):not([href^="javascript:"])' + )[0]; + } else { + firstPageLink = querySelectorAll( + axe._tree, + 'a[href]:not([href^="javascript:"])' + ).find(link => !isCurrentPageLink(link.actualNode)); + } // null will signify no first page link cache.set('firstPageLink', firstPageLink || null); @@ -45,5 +54,3 @@ function isSkipLink(element) { element.DOCUMENT_POSITION_FOLLOWING ); } - -export default isSkipLink; diff --git a/test/commons/dom/get-element-by-reference.js b/test/commons/dom/get-element-by-reference.js index 39f62fc3c4..9baa412cfa 100644 --- a/test/commons/dom/get-element-by-reference.js +++ b/test/commons/dom/get-element-by-reference.js @@ -31,6 +31,28 @@ describe('dom.getElementByReference', function() { assert.isNull(result); }); + it('should return node if target is found (href)', function() { + fixture.innerHTML = + 'Hi' + ''; + + var node = document.getElementById('link'), + expected = document.getElementById('target'), + result = axe.commons.dom.getElementByReference(node, 'href'); + + assert.equal(result, expected); + }); + + it('should return node if target is found (usemap)', function() { + fixture.innerHTML = + 'Hi' + ''; + + var node = document.getElementById('link'), + expected = document.getElementById('target'), + result = axe.commons.dom.getElementByReference(node, 'usemap'); + + assert.equal(result, expected); + }); + it('should prioritize ID', function() { fixture.innerHTML = 'Hi' + @@ -81,4 +103,21 @@ describe('dom.getElementByReference', function() { assert.equal(result, expected); }); + + it('should work with absolute links', function() { + var currentPage = window.location.origin + window.location.pathname; + + fixture.innerHTML = + 'Hi' + + '' + + ''; + + var node = document.getElementById('link'), + expected = document.getElementById('target'), + result = axe.commons.dom.getElementByReference(node, 'href'); + + assert.equal(result, expected); + }); }); diff --git a/test/commons/dom/is-current-page-link.js b/test/commons/dom/is-current-page-link.js new file mode 100644 index 0000000000..df840c75fd --- /dev/null +++ b/test/commons/dom/is-current-page-link.js @@ -0,0 +1,66 @@ +describe('is-current-page-link', function() { + var isCurrentPageLink = axe.commons.dom.isCurrentPageLink; + var currentPage = window.location.origin + window.location.pathname; + var base; + + afterEach(function() { + if (base) { + document.head.removeChild(base); + } + }); + + it('should return true for hash links', function() { + var anchor = document.createElement('a'); + anchor.href = '#main'; + document.body.appendChild(anchor); + assert.isTrue(isCurrentPageLink(anchor)); + }); + + it('should return true for relative links to the same page', function() { + var anchor = document.createElement('a'); + anchor.href = window.location.pathname; + assert.isTrue(isCurrentPageLink(anchor)); + }); + + it('should return true for absolute links to the same page', function() { + var anchor = document.createElement('a'); + anchor.href = currentPage; + assert.isTrue(isCurrentPageLink(anchor)); + }); + + it('should return true for angular skip links', function() { + var anchor = document.createElement('a'); + anchor.href = '/#main'; + assert.isTrue(isCurrentPageLink(anchor)); + }); + + it('should return false for just "#"', function() { + var anchor = document.createElement('a'); + anchor.href = '#'; + assert.isFalse(isCurrentPageLink(anchor)); + }); + + it('should return false for relative links to a different page', function() { + var anchor = document.createElement('a'); + anchor.href = '/foo/bar/index.html'; + assert.isFalse(isCurrentPageLink(anchor)); + }); + + it('should return false for absolute links to a different page', function() { + var anchor = document.createElement('a'); + anchor.href = 'https://my-page.com/foo/bar/index.html'; + assert.isFalse(isCurrentPageLink(anchor)); + }); + + it('should return false for angular router links (#!)', function() { + var anchor = document.createElement('a'); + anchor.href = '#!main'; + assert.isFalse(isCurrentPageLink(anchor)); + }); + + it('should return false for angular router links (#/)', function() { + var anchor = document.createElement('a'); + anchor.href = '#/main'; + assert.isFalse(isCurrentPageLink(anchor)); + }); +}); diff --git a/test/commons/dom/is-skip-link.js b/test/commons/dom/is-skip-link.js index 7e8a926c92..7d0017d954 100644 --- a/test/commons/dom/is-skip-link.js +++ b/test/commons/dom/is-skip-link.js @@ -2,9 +2,14 @@ describe('dom.isSkipLink', function() { 'use strict'; var fixture = document.getElementById('fixture'); + var baseEl; afterEach(function() { fixture.innerHTML = ''; + + if (baseEl) { + baseEl.parentNode.removeChild(baseEl); + } }); it('should return true if the href points to an ID', function() { @@ -35,6 +40,20 @@ describe('dom.isSkipLink', function() { assert.isTrue(axe.commons.dom.isSkipLink(node)); }); + it('should return false if the URI is angular #!', function() { + fixture.innerHTML = 'Click Here'; + axe._tree = axe.utils.getFlattenedTree(fixture); + var node = fixture.querySelector('a'); + assert.isFalse(axe.commons.dom.isSkipLink(node)); + }); + + it('should return false if the URI is angular #/', function() { + fixture.innerHTML = 'Click Here'; + axe._tree = axe.utils.getFlattenedTree(fixture); + var node = fixture.querySelector('a'); + assert.isFalse(axe.commons.dom.isSkipLink(node)); + }); + it('should return true for multiple skip-links', function() { fixture.innerHTML = 'Click Here>Click Here>Click Here>'; @@ -68,4 +87,41 @@ describe('dom.isSkipLink', function() { var node = fixture.querySelector('#skip-link'); assert.isTrue(axe.commons.dom.isSkipLink(node)); }); + + it('should return true for hash href that resolves to current page', function() { + fixture.innerHTML = + 'Click Here'; + axe._tree = axe.utils.getFlattenedTree(fixture); + var node = fixture.querySelector('a'); + assert.isTrue(axe.commons.dom.isSkipLink(node)); + }); + + it('should return true for absolute path hash href', function() { + var url = window.location.href; + fixture.innerHTML = 'Click Here'; + axe._tree = axe.utils.getFlattenedTree(fixture); + var node = fixture.querySelector('a'); + assert.isTrue(axe.commons.dom.isSkipLink(node)); + }); + + it('should return false for absolute path href that points to another document', function() { + var origin = window.location.origin; + fixture.innerHTML = + 'Click Here'; + axe._tree = axe.utils.getFlattenedTree(fixture); + var node = fixture.querySelector('a'); + assert.isFalse(axe.commons.dom.isSkipLink(node)); + }); + + it('should return false for href with tag that points to another document', function() { + baseEl = document.createElement('base'); + baseEl.href = 'https://www.google.com/'; + document.getElementsByTagName('head')[0].appendChild(baseEl); + + fixture.innerHTML = + 'Click Here'; + axe._tree = axe.utils.getFlattenedTree(fixture); + var node = fixture.querySelector('a'); + assert.isFalse(axe.commons.dom.isSkipLink(node)); + }); }); diff --git a/test/node/jsdom.js b/test/node/jsdom.js index e88b1393cb..8e03bd5ffd 100644 --- a/test/node/jsdom.js +++ b/test/node/jsdom.js @@ -12,6 +12,8 @@ var domStr = '' + '' + 'Hello' + + 'Main' + + '' + '' + ''; @@ -58,4 +60,104 @@ describe('jsdom axe-core', function() { assert.strictEqual(audit.allowedOrigins.length, 0); }); }); + + describe('isCurrentPageLink', function() { + // because axe only sets the window global when calling axe.run, + // we'll have to create a custom rule that calls + // isCurrentPageLink to gain access to the middle of a run with + // the proper window object + afterEach(function() { + axe.teardown(); + }); + + it('should return true if url starts with #', function() { + var dom = new jsdom.JSDOM(domStr); + var anchor = dom.window.document.getElementById('hash-link'); + + axe.configure({ + checks: [ + { + id: 'check-current-page-link', + evaluate: function() { + return axe.commons.dom.isCurrentPageLink(anchor) === true; + } + } + ], + rules: [ + { + id: 'check-current-page-link', + any: ['check-current-page-link'] + } + ] + }); + + return axe + .run(dom.window.document.documentElement, { + runOnly: ['check-current-page-link'] + }) + .then(function(results) { + assert.strictEqual(results.passes.length, 1); + }); + }); + + it('should return null for absolute link when url is not set', function() { + var dom = new jsdom.JSDOM(domStr); + var anchor = dom.window.document.getElementById('skip'); + + axe.configure({ + checks: [ + { + id: 'check-current-page-link', + evaluate: function() { + return axe.commons.dom.isCurrentPageLink(anchor) === null; + } + } + ], + rules: [ + { + id: 'check-current-page-link', + any: ['check-current-page-link'] + } + ] + }); + + return axe + .run(dom.window.document.documentElement, { + runOnly: ['check-current-page-link'] + }) + .then(function(results) { + assert.strictEqual(results.passes.length, 1); + }); + }); + + it('should return true for absolute link when url is set', function() { + var dom = new jsdom.JSDOM(domStr, { url: 'https://page.com' }); + var anchor = dom.window.document.getElementById('skip'); + + axe.configure({ + checks: [ + { + id: 'check-current-page-link', + evaluate: function() { + return axe.commons.dom.isCurrentPageLink(anchor) === true; + } + } + ], + rules: [ + { + id: 'check-current-page-link', + any: ['check-current-page-link'] + } + ] + }); + + return axe + .run(dom.window.document.documentElement, { + runOnly: ['check-current-page-link'] + }) + .then(function(results) { + assert.strictEqual(results.passes.length, 1); + }); + }); + }); });