diff --git a/packages/enzyme-test-suite/test/RSTTraversal-spec.jsx b/packages/enzyme-test-suite/test/RSTTraversal-spec.jsx index 1d37dcc14..5246ee3d2 100644 --- a/packages/enzyme-test-suite/test/RSTTraversal-spec.jsx +++ b/packages/enzyme-test-suite/test/RSTTraversal-spec.jsx @@ -4,7 +4,6 @@ import { expect } from 'chai'; import { elementToTree } from 'enzyme-adapter-utils'; import { hasClassName, - nodeHasProperty, treeForEach, treeFilter, pathToNode, @@ -46,96 +45,6 @@ describe('RSTTraversal', () => { }); }); - describe('nodeHasProperty', () => { - it('should find properties', () => { - function noop() {} - const node = $(
); - - expect(nodeHasProperty(node, 'onChange')).to.equal(true); - expect(nodeHasProperty(node, 'title', 'foo')).to.equal(true); - }); - - it('should not match on html attributes', () => { - const node = $(); - - expect(nodeHasProperty(node, 'for', 'foo')).to.equal(false); - }); - - it('should not find undefined properties', () => { - const node = $(); - - expect(nodeHasProperty(node, 'title')).to.equal(false); - }); - - it('should parse booleans', () => { - expect(nodeHasProperty(, 'foo', true)).to.equal(true); - expect(nodeHasProperty(, 'foo', false)).to.equal(false); - expect(nodeHasProperty(, 'foo', 'true')).to.equal(false); - expect(nodeHasProperty(, 'foo', false)).to.equal(true); - expect(nodeHasProperty(, 'foo', true)).to.equal(false); - expect(nodeHasProperty(, 'foo', 'false')).to.equal(false); - }); - - it('should parse numeric literals', () => { - expect(nodeHasProperty(, 'foo', 2.3)).to.equal(true); - expect(nodeHasProperty(, 'foo', 2)).to.equal(true); - expect(nodeHasProperty(, 'foo', '2abc')).to.equal(false); - expect(nodeHasProperty(, 'foo', 'abc2')).to.equal(false); - expect(nodeHasProperty(, 'foo', -2)).to.equal(true); - expect(nodeHasProperty(, 'foo', 2e8)).to.equal(true); - expect(nodeHasProperty(, 'foo', Infinity)).to.equal(true); - expect(nodeHasProperty(, 'foo', -Infinity)).to.equal(true); - }); - - it('should parse zeroes properly', () => { - expect(nodeHasProperty(, 'foo', 0)).to.equal(true); - expect(nodeHasProperty(, 'foo', +0)).to.equal(true); - expect(nodeHasProperty(, 'foo', -0)).to.equal(true); - expect(nodeHasProperty(, 'foo', 0)).to.equal(false); - expect(nodeHasProperty(, 'foo', -0)).to.equal(false); - expect(nodeHasProperty(, 'foo', 0)).to.equal(false); - expect(nodeHasProperty(, 'foo', -0)).to.equal(false); - }); - - it('should work with empty strings', () => { - expect(nodeHasProperty(, 'foo', '')).to.equal(true); - expect(nodeHasProperty(, 'foo', '')).to.equal(true); - expect(nodeHasProperty(, 'foo', '')).to.equal(false); - }); - - it('should work with NaN', () => { - expect(nodeHasProperty(, 'foo', NaN)).to.equal(true); - expect(nodeHasProperty(, 'foo', NaN)).to.equal(false); - }); - - it('should work with null', () => { - expect(nodeHasProperty(, 'foo', null)).to.equal(true); - expect(nodeHasProperty(, 'foo', null)).to.equal(false); - }); - - it('should work with false', () => { - expect(nodeHasProperty(, 'foo', false)).to.equal(true); - expect(nodeHasProperty(, 'foo', false)).to.equal(false); - }); - - it('should work with ±Infinity', () => { - expect(nodeHasProperty(, 'foo', Infinity)).to.equal(true); - expect(nodeHasProperty(, 'foo', +Infinity)).to.equal(true); - expect(nodeHasProperty(, 'foo', -Infinity)).to.equal(false); - expect(nodeHasProperty(, 'foo', 'Infinity')).to.equal(false); - expect(nodeHasProperty(, 'foo', NaN)).to.equal(false); - expect(nodeHasProperty(, 'foo', Infinity)).to.equal(false); - expect(nodeHasProperty(, 'foo', -Infinity)).to.equal(true); - expect(nodeHasProperty(, 'foo', Infinity)).to.equal(false); - expect(nodeHasProperty(, 'foo', Infinity)).to.equal(false); - expect(nodeHasProperty(, 'foo', '-Infinity')).to.equal(false); - expect(nodeHasProperty(, 'foo', NaN)).to.equal(false); - expect(nodeHasProperty(, 'foo', Infinity)).to.equal(false); - expect(nodeHasProperty(, 'foo', -Infinity)).to.equal(false); - expect(nodeHasProperty(, 'foo', -Infinity)).to.equal(false); - }); - }); - describe('treeForEach', () => { it('should be called once for a leaf node', () => { const spy = sinon.spy(); diff --git a/packages/enzyme-test-suite/test/selector-spec.jsx b/packages/enzyme-test-suite/test/selector-spec.jsx index 25b31f7ef..6b7649da3 100644 --- a/packages/enzyme-test-suite/test/selector-spec.jsx +++ b/packages/enzyme-test-suite/test/selector-spec.jsx @@ -21,8 +21,16 @@ const tests = [ }, ]; +let expectAttributeMatch; + describe('selectors', () => { tests.forEach(({ describeMethod, name, renderMethod }) => { + before(() => { + expectAttributeMatch = (element, selector, expected) => { + const wrapper = renderMethod(element); + expect(wrapper.is(selector)).to.equal(expected); + }; + }); describeMethod(name, () => { it('simple descendent', () => { const wrapper = renderMethod(( @@ -350,6 +358,124 @@ describe('selectors', () => { expect(wrapper.find('Wrapped(Foo)')).to.have.lengthOf(1); expect(wrapper.find('Wrapped(Twice(Bar))')).to.have.lengthOf(1); }); + + it('should parse booleans', () => { + expectAttributeMatch(, '[hidden=true]', true); + expectAttributeMatch(, '[hidden=false]', false); + expectAttributeMatch(, '[hidden="true"]', false); + expectAttributeMatch(, '[hidden=false]', true); + expectAttributeMatch(, '[hidden=true]', false); + expectAttributeMatch(, '[hidden="false"]', false); + }); + + it('should parse numeric literals', () => { + expectAttributeMatch(, '[data-foo=2.3]', true); + expectAttributeMatch(, '[data-foo=2]', true); + expectAttributeMatch(, '[data-foo="2abc"]', false); + expectAttributeMatch(, '[data-foo="abc2"]', false); + expectAttributeMatch(, '[data-foo=-2]', true); + // @TODO this is failing due to a parser issue + // expectAttributeMatch(, '[data-foo=2e8]', true); + expectAttributeMatch(, '[data-foo=Infinity]', true); + expectAttributeMatch(, '[data-foo=-Infinity]', false); + expectAttributeMatch(, '[data-foo=-Infinity]', true); + expectAttributeMatch(, '[data-foo=Infinity]', false); + }); + + it('should parse zeroes properly', () => { + expectAttributeMatch(, '[data-foo=0]', true); + expectAttributeMatch(, '[data-foo=+0]', true); + expectAttributeMatch(, '[data-foo=-0]', true); + expectAttributeMatch(, '[data-foo=0]', false); + expectAttributeMatch(, '[data-foo=-0]', false); + expectAttributeMatch(, '[data-foo=0]', false); + expectAttributeMatch(, '[data-foo=-0]', false); + }); + + it('should work with empty strings', () => { + expectAttributeMatch(, '[className=""]', true); + expectAttributeMatch(, '[className=""]', true); + expectAttributeMatch(, '[className=""]', false); + }); + + it('should work with NaN', () => { + expectAttributeMatch(, '[data-foo=NaN]', true); + expectAttributeMatch(, '[data-foo=NaN]', false); + }); + + it('should work with null', () => { + expectAttributeMatch(, '[data-foo=null]', true); + expectAttributeMatch(, '[data-foo=null]', false); + }); + + it('should work with false', () => { + expectAttributeMatch(, '[data-foo=false]', true); + expectAttributeMatch(, '[data-foo=false]', false); + }); + it('should work with ±Infinity', () => { + expectAttributeMatch(, '[data-foo=Infinity]', true); + expectAttributeMatch(, '[data-foo=+Infinity]', true); + expectAttributeMatch(, '[data-foo=-Infinity]', false); + expectAttributeMatch(, '[data-foo=NaN]', false); + expectAttributeMatch(, '[data-foo=Infinity]', false); + expectAttributeMatch(, '[data-foo=-Infinity]', false); + expectAttributeMatch(, '[data-foo=-Infinity]', true); + expectAttributeMatch(, '[data-foo=Infinity]', false); + expectAttributeMatch(, '[data-foo="-Infinity"]', false); + expectAttributeMatch(, '[data-foo=NaN]', false); + expectAttributeMatch(, '[data-foo=Infinity]', false); + expectAttributeMatch(, '[data-foo=-Infinity]', false); + }); + + it('whitespace list attribute selector', () => { + expectAttributeMatch(, '[rel~="bar"]', true); + expectAttributeMatch(, '[rel~="baz"]', true); + expectAttributeMatch(, '[rel~="foo"]', true); + expectAttributeMatch(, '[rel~="foo bar"]', false); + expectAttributeMatch(, '[rel~=1]', false); + expectAttributeMatch(, '[rel~=1]', false); + }); + + it('hypen attribute selector', () => { + expectAttributeMatch(, '[hrefLang|="en"]', true); + expectAttributeMatch(, '[hrefLang|="en-US"]', true); + expectAttributeMatch(, '[hrefLang|="US"]', false); + expectAttributeMatch(, '[hrefLang|="enUS"]', false); + expectAttributeMatch(, '[hrefLang|=1]', false); + expectAttributeMatch(, '[hrefLang|=1]', false); + }); + + it('prefix attribute operator', () => { + expectAttributeMatch(, '[src^="foo"]', true); + expectAttributeMatch(, '[src^="foo-bar"]', true); + expectAttributeMatch(, '[src^="foo-bar.jpg"]', true); + expectAttributeMatch(, '[src^="bar"]', false); + expectAttributeMatch(, '[src^=""]', false); + expectAttributeMatch(, '[src^=1]', false); + expectAttributeMatch(, '[src^=1]', false); + }); + + it('suffix attribute operator', () => { + expectAttributeMatch(, '[src$=".jpg"]', true); + expectAttributeMatch(, '[src$="bar.jpg"]', true); + expectAttributeMatch(, '[src$="foo-bar.jpg"]', true); + expectAttributeMatch(, '[src$="foo"]', false); + expectAttributeMatch(, '[src$=""]', false); + expectAttributeMatch(, '[src$=1]', false); + expectAttributeMatch(, '[src$=1]', false); + }); + + it('substring attribute operator', () => { + expectAttributeMatch(, '[id*="foo"]', true); + expectAttributeMatch(, '[id*="foo bar"]', true); + expectAttributeMatch(, '[id*="foo bar baz"]', true); + expectAttributeMatch(, '[id*="foo "]', true); + expectAttributeMatch(, '[id*="fo"]', true); + expectAttributeMatch(, '[id*="foz"]', false); + expectAttributeMatch(, '[id*=1]', false); + expectAttributeMatch(, '[id*=1]', false); + expectAttributeMatch(, '[id*=1]', false); + }); }); }); }); diff --git a/packages/enzyme/package.json b/packages/enzyme/package.json index cfd84c856..8698e1935 100644 --- a/packages/enzyme/package.json +++ b/packages/enzyme/package.json @@ -36,6 +36,7 @@ "dependencies": { "cheerio": "^1.0.0-rc.2", "function.prototype.name": "^1.0.3", + "has": "^1.0.1", "is-subset": "^0.1.1", "lodash": "^4.17.4", "object-is": "^1.0.1", diff --git a/packages/enzyme/src/Utils.js b/packages/enzyme/src/Utils.js index e2f3b1425..fb814330e 100644 --- a/packages/enzyme/src/Utils.js +++ b/packages/enzyme/src/Utils.js @@ -29,7 +29,7 @@ export function isCustomComponentElement(inst, adapter) { return !!inst && adapter.isValidElement(inst) && typeof inst.type === 'function'; } -function propsOfNode(node) { +export function propsOfNode(node) { return entries((node && node.props) || {}) .filter(([, value]) => typeof value !== 'undefined') .reduce((acc, [key, value]) => Object.assign(acc, { [key]: value }), {}); @@ -219,25 +219,6 @@ export function AND(fns) { return x => fnsReversed.every(fn => fn(x)); } -export function nodeHasProperty(node, propKey, propValue) { - const nodeProps = propsOfNode(node); - const descriptor = Object.getOwnPropertyDescriptor(nodeProps, propKey); - if (descriptor && descriptor.get) { - return false; - } - const nodePropValue = nodeProps[propKey]; - - if (typeof nodePropValue === 'undefined') { - return false; - } - - if (typeof propValue !== 'undefined') { - return is(nodePropValue, propValue); - } - - return Object.prototype.hasOwnProperty.call(nodeProps, propKey); -} - export function displayNameOfNode(node) { if (!node) return null; diff --git a/packages/enzyme/src/selectors.js b/packages/enzyme/src/selectors.js index 421498ecb..0800a2374 100644 --- a/packages/enzyme/src/selectors.js +++ b/packages/enzyme/src/selectors.js @@ -3,6 +3,8 @@ import values from 'object.values'; import isEmpty from 'lodash/isEmpty'; import flatten from 'lodash/flatten'; import unique from 'lodash/uniq'; +import is from 'object-is'; +import has from 'has'; import { treeFilter, nodeHasId, @@ -11,7 +13,7 @@ import { childrenOfNode, hasClassName, } from './RSTTraversal'; -import { nodeHasType, nodeHasProperty } from './Utils'; +import { nodeHasType, propsOfNode } from './Utils'; // our CSS selector parser instance const parser = createParser(); @@ -32,6 +34,13 @@ const ATTRIBUTE_VALUE = 'attributeValueSelector'; const PSEUDO_CLASS = 'pseudoClassSelector'; const PSEUDO_ELEMENT = 'pseudoElementSelector'; +const EXACT_ATTRIBUTE_OPERATOR = '='; +const WHITELIST_ATTRIBUTE_OPERATOR = '~='; +const HYPHENATED_ATTRIBUTE_OPERATOR = '|='; +const PREFIX_ATTRIBUTE_OPERATOR = '^='; +const SUFFIX_ATTRIBUTE_OPERATOR = '$='; +const SUBSTRING_ATTRIBUTE_OPERATOR = '*='; + /** * Calls reduce on a array of nodes with the passed * function, returning only unique results. @@ -55,6 +64,80 @@ function safelyGenerateTokens(selector) { } } +function matchAttributeSelector(node, token) { + const { operator, value, name } = token; + const nodeProps = propsOfNode(node); + const descriptor = Object.getOwnPropertyDescriptor(nodeProps, name); + if (descriptor && descriptor.get) { + return false; + } + const nodePropValue = nodeProps[name]; + if (typeof nodePropValue === 'undefined') { + return false; + } + if (token.type === ATTRIBUTE_PRESENCE) { + return has(nodeProps, token.name); + } + // Only the exact value operator ("=") can match non-strings + if (typeof nodePropValue !== 'string' || typeof value !== 'string') { + if (operator !== EXACT_ATTRIBUTE_OPERATOR) { + return false; + } + } + switch (operator) { + /** + * Represents an element with the att attribute whose value is exactly "val". + * @example + * [attr="val"] matches attr="val" + */ + case EXACT_ATTRIBUTE_OPERATOR: + return is(nodePropValue, value); + /** + * Represents an element with the att attribute whose value is a whitespace-separated + * list of words, one of which is exactly + * @example + * [rel~="copyright"] matches rel="copyright other" + */ + case WHITELIST_ATTRIBUTE_OPERATOR: + return nodePropValue.split(' ').indexOf(value) !== -1; + /** + * Represents an element with the att attribute, its value either being exactly the + * value or beginning with the value immediately followed by "-" + * @example + * [hreflang|="en"] matches hreflang="en-US" + */ + case HYPHENATED_ATTRIBUTE_OPERATOR: + return nodePropValue === value || nodePropValue.startsWith(`${value}-`); + /** + * Represents an element with the att attribute whose value begins with the prefix value. + * If the value is the empty string then the selector does not represent anything. + * @example + * [type^="image"] matches type="imageobject" + */ + case PREFIX_ATTRIBUTE_OPERATOR: + return value === '' ? false : nodePropValue.slice(0, value.length) === value; + /** + * Represents an element with the att attribute whose value ends with the suffix value. + * If the value is the empty string then the selector does not represent anything. + * @example + * [type$="image"] matches type="imageobject" + */ + case SUFFIX_ATTRIBUTE_OPERATOR: + return value === '' ? false : nodePropValue.slice(-value.length) === value; + /** + * Represents an element with the att attribute whose value contains at least one + * instance of the value. If value is the empty string then the + * selector does not represent anything. + * @example + * [title*="hello"] matches title="well hello there" + */ + case SUBSTRING_ATTRIBUTE_OPERATOR: + return value === '' ? false : nodePropValue.indexOf(value) !== -1; + default: + throw new Error(`Enzyme::Selector: Unknown attribute selector operator "${operator}"`); + } +} + /** * Takes a node and a token and determines if the node * matches the predicate defined by the token. @@ -90,14 +173,14 @@ function nodeMatchesToken(node, token) { * @example '[disabled]' matches */ case ATTRIBUTE_PRESENCE: - return nodeHasProperty(node, token.name); + return matchAttributeSelector(node, token); /** * Matches if an attribute is present with the * provided value * @example '[data-foo=foo]' matches */ case ATTRIBUTE_VALUE: - return nodeHasProperty(node, token.name, token.value); + return matchAttributeSelector(node, token); case PSEUDO_ELEMENT: case PSEUDO_CLASS: throw new Error('Enzyme::Selector does not support psuedo-element or psuedo-class selectors.');