diff --git a/axe.d.ts b/axe.d.ts index 8ecd9de75a..5c4197a872 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -70,16 +70,19 @@ declare namespace axe { | LabelledShadowDomSelector | LabelledFramesSelector; type SelectorList = Array | NodeList; + type ContextProp = Selector | SelectorList; type ContextObject = | { - include: Selector | SelectorList; - exclude?: Selector | SelectorList; + include: ContextProp; + exclude?: ContextProp; } | { - exclude: Selector | SelectorList; - include?: Selector | SelectorList; + exclude: ContextProp; + include?: ContextProp; }; - type ElementContext = Selector | SelectorList | ContextObject; + type ContextSpec = ContextProp | ContextObject; + /** Synonym to ContextSpec */ + type ElementContext = ContextSpec; type SerialSelector = | BaseSelector @@ -406,6 +409,16 @@ declare namespace axe { shadowSelect: (selector: CrossTreeSelector) => Element | null; shadowSelectAll: (selector: CrossTreeSelector) => Element[]; getStandards(): Required; + isContextSpec: (context: unknown) => context is ContextSpec; + isContextObject: (context: unknown) => context is ContextObject; + isContextProp: (context: unknown) => context is ContextProp; + isLabelledFramesSelector: ( + selector: unknown + ) => selector is LabelledFramesSelector; + isLabelledShadowDomSelector: ( + selector: unknown + ) => selector is LabelledShadowDomSelector; + DqElement: new ( elm: Element, options?: { absolutePaths?: boolean } diff --git a/lib/core/base/context/normalize-context.js b/lib/core/base/context/normalize-context.js index 3eb9bea77b..a3dbb28ef2 100644 --- a/lib/core/base/context/normalize-context.js +++ b/lib/core/base/context/normalize-context.js @@ -1,4 +1,12 @@ -import { assert as utilsAssert } from '../../utils'; +import { + assert as utilsAssert, + objectHasOwn, + isArrayLike, + isContextObject, + isContextProp, + isLabelledFramesSelector, + isLabelledShadowDomSelector +} from '../../utils'; /** * Normalize the input of "context" so that many different methods of input are accepted @@ -29,16 +37,6 @@ export function normalizeContext(contextSpec) { return { include, exclude }; } -/** - * Determine if some value can be parsed as a context - * @private - * @param {Mixed} contextSpec The configuration object passed to `Context` - * @return {boolea} - */ -export function isContextSpec(contextSpec) { - return isContextObject(contextSpec) || isContextProp(contextSpec); -} - function normalizeContextList(selectorList = []) { const normalizedList = []; if (!isArrayLike(selectorList)) { @@ -89,30 +87,6 @@ function normalizeFrameSelectors(frameSelectors) { return normalizedSelectors; } -function isContextObject(contextSpec) { - return ['include', 'exclude'].some( - prop => objectHasOwn(contextSpec, prop) && isContextProp(contextSpec[prop]) - ); -} - -function isContextProp(contextList) { - return ( - typeof contextList === 'string' || - contextList instanceof window.Node || - isLabelledFramesSelector(contextList) || - isLabelledShadowDomSelector(contextList) || - isArrayLike(contextList) - ); -} - -function isLabelledFramesSelector(selector) { - return objectHasOwn(selector, 'fromFrames'); -} - -function isLabelledShadowDomSelector(selector) { - return objectHasOwn(selector, 'fromShadowDom'); -} - function assertLabelledFrameSelector(selector) { assert( Array.isArray(selector.fromFrames), @@ -157,16 +131,6 @@ function isShadowSelector(selector) { ); } -function isArrayLike(arr) { - return ( - // Avoid DOM weirdness - arr && - typeof arr === 'object' && - typeof arr.length === 'number' && - arr instanceof window.Node === false - ); -} - // Wrapper to ensure the correct message function assert(bool, str) { utilsAssert( @@ -174,11 +138,3 @@ function assert(bool, str) { `Invalid context; ${str}\nSee: https://github.com/dequelabs/axe-core/blob/master/doc/context.md` ); } - -// Wrapper to prevent throwing for non-objects & null -function objectHasOwn(obj, prop) { - if (!obj || typeof obj !== 'object') { - return false; - } - return Object.prototype.hasOwnProperty.call(obj, prop); -} diff --git a/lib/core/public/run/normalize-run-params.js b/lib/core/public/run/normalize-run-params.js index 96c4b18f4e..c952684b92 100644 --- a/lib/core/public/run/normalize-run-params.js +++ b/lib/core/public/run/normalize-run-params.js @@ -1,5 +1,4 @@ -import { clone } from '../../utils'; -import { isContextSpec } from '../../base/context/normalize-context'; +import { clone, isContextSpec } from '../../utils'; /** * Normalize the optional params of axe.run() diff --git a/lib/core/utils/index.js b/lib/core/utils/index.js index 360c1d7283..ac1286d6cf 100644 --- a/lib/core/utils/index.js +++ b/lib/core/utils/index.js @@ -43,6 +43,14 @@ export { default as getStyleSheetFactory } from './get-stylesheet-factory'; export { default as getXpath } from './get-xpath'; export { default as getAncestry } from './get-ancestry'; export { default as injectStyle } from './inject-style'; +export { default as isArrayLike } from './is-array-like'; +export { + isContextSpec, + isContextObject, + isContextProp, + isLabelledShadowDomSelector, + isLabelledFramesSelector +} from './is-context'; export { default as isHidden } from './is-hidden'; export { default as isHtmlElement } from './is-html-element'; export { default as isNodeInContext } from './is-node-in-context'; @@ -59,6 +67,7 @@ export { default as mergeResults } from './merge-results'; export { default as nodeSerializer } from './node-serializer'; export { default as nodeSorter } from './node-sorter'; export { default as nodeLookup } from './node-lookup'; +export { default as objectHasOwn } from './object-has-own'; export { default as parseCrossOriginStylesheet } from './parse-crossorigin-stylesheet'; export { default as parseSameOriginStylesheet } from './parse-sameorigin-stylesheet'; export { default as parseStylesheet } from './parse-stylesheet'; diff --git a/lib/core/utils/is-array-like.js b/lib/core/utils/is-array-like.js new file mode 100644 index 0000000000..97e7b4f910 --- /dev/null +++ b/lib/core/utils/is-array-like.js @@ -0,0 +1,15 @@ +/** + * Checks if a value is array-like. + * + * @param {any} arr - The value to check. + * @returns {boolean} - Returns true if the value is array-like, false otherwise. + */ +export default function isArrayLike(arr) { + return ( + !!arr && + typeof arr === 'object' && + typeof arr.length === 'number' && + // Avoid DOM weirdness + arr instanceof window.Node === false + ); +} diff --git a/lib/core/utils/is-context.js b/lib/core/utils/is-context.js new file mode 100644 index 0000000000..fcee309ac2 --- /dev/null +++ b/lib/core/utils/is-context.js @@ -0,0 +1,53 @@ +import objectHasOwn from './object-has-own'; +import isArrayLike from './is-array-like'; + +/** + * Determine if some value can be parsed as a context + * @private + * @param {Mixed} contextSpec The configuration object passed to `Context` + * @return {boolea} + */ +export function isContextSpec(contextSpec) { + return isContextObject(contextSpec) || isContextProp(contextSpec); +} + +/** + * Checks if the given context specification is a valid context object. + * + * @param {Object} contextSpec - The context specification object to check. + * @returns {boolean} - Returns true if the context specification is a valid context object, otherwise returns false. + */ +export function isContextObject(contextSpec) { + return ['include', 'exclude'].some( + prop => objectHasOwn(contextSpec, prop) && isContextProp(contextSpec[prop]) + ); +} + +/** + * Checks if the given contextList is a valid context property. + * @param {string|Node|Array} contextList - The contextList to check. + * @returns {boolean} - Returns true if the contextList is a valid context property, otherwise false. + */ +export function isContextProp(contextList) { + return ( + typeof contextList === 'string' || + contextList instanceof window.Node || + isLabelledFramesSelector(contextList) || + isLabelledShadowDomSelector(contextList) || + isArrayLike(contextList) + ); +} + +export function isLabelledFramesSelector(selector) { + // This doesn't guarantee the selector is valid. + // Just that this isn't a runOptions object + // Normalization will ignore invalid selectors + return objectHasOwn(selector, 'fromFrames'); +} + +export function isLabelledShadowDomSelector(selector) { + // This doesn't guarantee the selector is valid. + // Just that this isn't a runOptions object + // Normalization will ignore invalid selectors + return objectHasOwn(selector, 'fromShadowDom'); +} diff --git a/lib/core/utils/object-has-own.js b/lib/core/utils/object-has-own.js new file mode 100644 index 0000000000..4ac189bd7a --- /dev/null +++ b/lib/core/utils/object-has-own.js @@ -0,0 +1,7 @@ +// Wrapper to prevent throwing for non-objects & null +export default function objectHasOwn(obj, prop) { + if (!obj || typeof obj !== 'object') { + return false; + } + return Object.prototype.hasOwnProperty.call(obj, prop); +} diff --git a/test/core/utils/is-array-like.js b/test/core/utils/is-array-like.js new file mode 100644 index 0000000000..8c999f37da --- /dev/null +++ b/test/core/utils/is-array-like.js @@ -0,0 +1,30 @@ +describe('axe.utils.isArrayLike', () => { + const isArrayLike = axe.utils.isArrayLike; + + it('is true for an array', () => { + assert.isTrue(isArrayLike([])); + }); + + it('is true for an array-like object', () => { + assert.isTrue(isArrayLike({ length: 1 })); + }); + + it('is false for strings (which also have .length)', () => { + assert.isFalse(isArrayLike('string')); + }); + + it('is false for a Node with .length', () => { + const div = document.createElement('div'); + div.length = 123; + assert.isFalse(isArrayLike(div)); + }); + + it('is false for non-array-like objects', () => { + assert.isFalse(isArrayLike({})); + assert.isFalse(isArrayLike(null)); + assert.isFalse(isArrayLike(undefined)); + assert.isFalse(isArrayLike(1)); + assert.isFalse(isArrayLike(true)); + assert.isFalse(isArrayLike(false)); + }); +}); diff --git a/test/core/utils/is-context.js b/test/core/utils/is-context.js new file mode 100644 index 0000000000..2a3043186a --- /dev/null +++ b/test/core/utils/is-context.js @@ -0,0 +1,149 @@ +describe('axe.utils isContext* methods', () => { + const { isContextProp, isContextObject, isContextSpec } = axe.utils; + + const methods = [ + { name: 'isLabelledShadowDomSelector', prop: 'fromShadowDom' }, + { name: 'isLabelledFramesSelector', prop: 'fromFrames' } + ]; + + methods.forEach(({ name, prop }) => { + describe(name, () => { + const method = axe.utils[name]; + it(`is true for an object with '${prop}'`, () => { + assert.isTrue(method({ [prop]: true })); + }); + + it('is false for an object without `fromShadowDom`', () => { + assert.isFalse(method({})); + }); + + it('is false for non-objects', () => { + assert.isFalse(method('string')); + assert.isFalse(method(1)); + assert.isFalse(method([])); + assert.isFalse(method(null)); + }); + + it('is false if the property comes from the prototype', () => { + assert.isFalse(method(Object.create({ [prop]: true }))); + }); + }); + }); + + describe('isContextProp', () => { + it('is true for a string', () => { + assert.isTrue(isContextProp('string')); + }); + + it('is true for a Node', () => { + assert.isTrue(isContextProp(document.createElement('div'))); + }); + + it('is true for an array', () => { + assert.isTrue(isContextProp([])); + }); + + it('is true for an object with .length', () => { + assert.isTrue(isContextProp({ length: 1 })); + }); + + it('is true for an object with `fromFrames`', () => { + assert.isTrue(isContextProp({ fromFrames: true })); + }); + + it('is true for an object with `fromShadowDom`', () => { + assert.isTrue(isContextProp({ fromShadowDom: true })); + }); + + it('is false for other objects', () => { + assert.isFalse(isContextProp({})); + assert.isFalse(isContextProp({ exclude: [] })); + assert.isFalse(isContextProp({ include: true })); + assert.isFalse(isContextProp({ runOnly: 'rules' })); + }); + + it('is false for other types', () => { + assert.isFalse(isContextProp(1)); + assert.isFalse(isContextProp(null)); + assert.isFalse(isContextProp(undefined)); + }); + }); + + describe('isContextObject', () => { + it('is false if not an object `include` or `exclude`', () => { + assert.isFalse(isContextObject(true)); + assert.isFalse(isContextObject(null)); + assert.isFalse(isContextObject(1)); + assert.isFalse(isContextObject({ foo: 'bar' })); + }); + + it('is true for an object with `include` with a context prop', () => { + assert.isTrue(isContextObject({ include: 'string' })); + assert.isTrue( + isContextObject({ include: document.createElement('div') }) + ); + assert.isTrue(isContextObject({ include: [] })); + assert.isTrue(isContextObject({ include: { length: 1 } })); + assert.isTrue(isContextObject({ include: { fromFrames: true } })); + assert.isTrue(isContextObject({ include: { fromShadowDom: true } })); + }); + + it('is false for an object with `include` that is not a context prop', () => { + assert.isFalse(isContextObject({ include: false })); + assert.isFalse(isContextObject({ include: null })); + assert.isFalse(isContextObject({ include: 123 })); + assert.isFalse(isContextObject({ include: { something: 'else' } })); + }); + + it('is true for an object with `exclude` with a context prop', () => { + assert.isTrue(isContextObject({ exclude: 'string' })); + assert.isTrue( + isContextObject({ exclude: document.createElement('div') }) + ); + assert.isTrue(isContextObject({ exclude: [] })); + assert.isTrue(isContextObject({ exclude: { length: 1 } })); + assert.isTrue(isContextObject({ exclude: { fromFrames: true } })); + assert.isTrue(isContextObject({ exclude: { fromShadowDom: true } })); + }); + + it('is false for an object with `exclude` that is not a context prop', () => { + assert.isFalse(isContextObject({ exclud: false })); + assert.isFalse(isContextObject({ exclud: null })); + assert.isFalse(isContextObject({ exclud: 123 })); + assert.isFalse(isContextObject({ exclude: { something: 'else' } })); + }); + + it('is false if `include` is on the prototype', () => { + assert.isFalse(isContextObject(Object.create({ include: 'string' }))); + }); + + it('is false if `exclude` is on the prototype', () => { + assert.isFalse(isContextObject(Object.create({ exclude: 'string' }))); + }); + + it('is true for an object with both `include` and `exclude`', () => { + assert.isTrue(isContextObject({ include: 'string', exclude: 'string' })); + }); + }); + + describe('isContextSpec', () => { + it('is true for a context object', () => { + assert.isTrue(isContextSpec({ include: 'string' })); + assert.isTrue(isContextSpec({ exclude: ['string'] })); + }); + + it('is true for a context prop', () => { + assert.isTrue(isContextSpec('string')); + }); + + it('is false for other types', () => { + assert.isFalse(isContextSpec(true)); + assert.isFalse(isContextSpec(null)); + assert.isFalse(isContextSpec(1)); + assert.isFalse(isContextSpec({})); + assert.isFalse(isContextSpec({ include: null })); + assert.isFalse(isContextSpec({ runOnly: 'foo' })); + assert.isFalse(isContextSpec(Object.create({ include: 'foo' }))); + }); + }); +}); diff --git a/test/core/utils/object-has-own.js b/test/core/utils/object-has-own.js new file mode 100644 index 0000000000..c4a772b77b --- /dev/null +++ b/test/core/utils/object-has-own.js @@ -0,0 +1,22 @@ +describe('axe.utils.objectHasOwn', () => { + const objectHasOwn = axe.utils.objectHasOwn; + + it('is true for an object with a property', () => { + assert.isTrue(objectHasOwn({ prop: true }, 'prop')); + }); + + it('is false for an object without a property', () => { + assert.isFalse(objectHasOwn({}, 'prop')); + }); + + it('is false for non-objects', () => { + assert.isFalse(objectHasOwn('string', 'prop')); + assert.isFalse(objectHasOwn(1, 'prop')); + assert.isFalse(objectHasOwn([], 'prop')); + assert.isFalse(objectHasOwn(null, 'prop')); + }); + + it('is false if the property comes from the prototype', () => { + assert.isFalse(objectHasOwn(Object.create({ prop: true }), 'prop')); + }); +}); diff --git a/typings/axe-core/axe-core-tests.ts b/typings/axe-core/axe-core-tests.ts index 5d421307a8..1b8a9fda60 100644 --- a/typings/axe-core/axe-core-tests.ts +++ b/typings/axe-core/axe-core-tests.ts @@ -431,6 +431,18 @@ axe.cleanup(); const dqElement = new axe.utils.DqElement(document.body); const element = axe.utils.shadowSelect(dqElement.selector[0]); const uuid = axe.utils.uuid() as string; +let unknownContext: unknown = JSON.parse('{ foo: "bar" }'); +if (axe.utils.isLabelledShadowDomSelector(unknownContext)) { + let context: axe.LabelledShadowDomSelector = unknownContext; +} else if (axe.utils.isLabelledFramesSelector(unknownContext)) { + let context: axe.LabelledFramesSelector = unknownContext; +} else if (axe.utils.isContextObject(unknownContext)) { + let context: axe.ContextObject = unknownContext; +} else if (axe.utils.isContextProp(unknownContext)) { + let context: axe.ContextProp = unknownContext; +} else if (axe.utils.isContextSpec(unknownContext)) { + let context: axe.ContextSpec = unknownContext; +} // Commons axe.commons.aria.getRoleType('img');