UNPKG

enzyme

Version:

JavaScript Testing utilities for React

460 lines (433 loc) 15.6 kB
import { createParser } from 'rst-selector-parser'; import values from 'object.values'; import flat from 'array.prototype.flat'; import is from 'object-is'; import has from 'has'; import elementsByConstructor from 'html-element-map/byConstructor'; import { treeFilter, nodeHasId, findParentNode, nodeMatchesObjectProps, childrenOfNode, hasClassName, } from './RSTTraversal'; import { nodeHasType, propsOfNode } from './Utils'; import getAdapter from './getAdapter'; // our CSS selector parser instance const parser = createParser(); // Combinators that allow you to chance selectors const CHILD = 'childCombinator'; const ADJACENT_SIBLING = 'adjacentSiblingCombinator'; const GENERAL_SIBLING = 'generalSiblingCombinator'; const DESCENDANT = 'descendantCombinator'; // Selectors for targeting elements const SELECTOR = 'selector'; const TYPE_SELECTOR = 'typeSelector'; const CLASS_SELECTOR = 'classSelector'; const ID_SELECTOR = 'idSelector'; const UNIVERSAL_SELECTOR = 'universalSelector'; const ATTRIBUTE_PRESENCE = 'attributePresenceSelector'; const ATTRIBUTE_VALUE = 'attributeValueSelector'; // @TODO we dont support these, throw if they are used 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 = '*='; function unique(arr) { return [...new Set(arr)]; } /** * Calls reduce on a array of nodes with the passed * function, returning only unique results. * @param {Function} fn * @param {Array<Node>} nodes */ function uniqueReduce(fn, nodes) { return unique(nodes.reduce(fn, [])); } /** * Takes a CSS selector and returns a set of tokens parsed * by scalpel. * @param {String} selector */ function safelyGenerateTokens(selector) { try { return parser.parse(selector); } catch (err) { throw new Error(`Failed to parse selector: ${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}"`); } } function matchPseudoSelector(node, token, root) { const { name, parameters } = token; if (name === 'not') { // eslint-disable-next-line no-use-before-define return parameters.every((selector) => reduceTreeBySelector(selector, node).length === 0); } if (name === 'empty') { return treeFilter(node, (n) => n !== node).length === 0; } if (name === 'first-child') { const { rendered } = findParentNode(root, node); const [firstChild] = rendered; return firstChild === node; } if (name === 'last-child') { const { rendered } = findParentNode(root, node); return rendered[rendered.length - 1] === node; } if (name === 'focus') { if (typeof document === 'undefined') { throw new Error('Enzyme::Selector does not support the ":focus" pseudo-element without a global `document`.'); } const adapter = getAdapter(); /* eslint-env browser */ return document.activeElement && adapter.nodeToHostNode(node) === document.activeElement; } throw new TypeError(`Enzyme::Selector does not support the "${token.name}" pseudo-element or pseudo-class selectors.`); } /** * Takes a node and a token and determines if the node * matches the predicate defined by the token. * @param {Node} node * @param {Token} token */ function nodeMatchesToken(node, token, root) { if (node === null || typeof node === 'string') { return false; } switch (token.type) { /** * Match every node * @example '*' matches every node */ case UNIVERSAL_SELECTOR: return true; /** * Match against the className prop * @example '.active' matches <div className='active' /> */ case CLASS_SELECTOR: return hasClassName(node, token.name); /** * Simple type matching * @example 'div' matches <div /> */ case TYPE_SELECTOR: return nodeHasType(node, token.name); /** * Match against the `id` prop * @example '#nav' matches <ul id="nav" /> */ case ID_SELECTOR: return nodeHasId(node, token.name); /** * Matches if an attribute is present, regardless * of its value * @example '[disabled]' matches <a disabled /> */ case ATTRIBUTE_PRESENCE: return matchAttributeSelector(node, token); /** * Matches if an attribute is present with the * provided value * @example '[data-foo=foo]' matches <div data-foo="foo" /> */ case ATTRIBUTE_VALUE: return matchAttributeSelector(node, token); case PSEUDO_ELEMENT: case PSEUDO_CLASS: return matchPseudoSelector(node, token, root); default: throw new Error(`Unknown token type: ${token.type}`); } } /** * Returns a predicate function that checks if a * node matches every token in the body of a selector * token. * @param {Token} token */ function buildPredicateFromToken(token, root) { return (node) => token.body.every((bodyToken) => nodeMatchesToken(node, bodyToken, root)); } /** * Returns whether a parsed selector is a complex selector, which * is defined as a selector that contains combinators. * @param {Array<Token>} tokens */ function isComplexSelector(tokens) { return tokens.some((token) => token.type !== SELECTOR); } /** * Takes a component constructor, object, or string representing * a simple selector and returns a predicate function that can * be applied to a single node. * @param {EnzymeSelector} selector */ export function buildPredicate(selector) { // If the selector is a string, parse it as a simple CSS selector if (typeof selector === 'string') { const tokens = safelyGenerateTokens(selector); if (isComplexSelector(tokens)) { throw new TypeError('This method does not support complex CSS selectors'); } // Simple selectors only have a single selector token return buildPredicateFromToken(tokens[0]); } // If the selector is an element type, check if the node's type matches const adapter = getAdapter(); const isElementType = adapter.isValidElementType ? adapter.isValidElementType(selector) : typeof selector === 'function'; if (isElementType) { return (node) => adapter.matchesElementType(node, selector); } // If the selector is an non-empty object, treat the keys/values as props if (typeof selector === 'object') { if (!Array.isArray(selector) && selector !== null && Object.keys(selector).length > 0) { const hasUndefinedValues = values(selector).some((value) => typeof value === 'undefined'); if (hasUndefinedValues) { throw new TypeError('Enzyme::Props can’t have `undefined` values. Try using ‘findWhere()’ instead.'); } return (node) => nodeMatchesObjectProps(node, selector); } throw new TypeError('Enzyme::Selector does not support an array, null, or empty object as a selector'); } throw new TypeError('Enzyme::Selector expects a string, object, or valid element type (Component Constructor)'); } /** * Matches only nodes which are adjacent siblings (direct next sibling) * against a predicate, returning those that match. * @param {Array<Node>} nodes * @param {Function} predicate * @param {Node} root */ function matchAdjacentSiblings(nodes, predicate, root) { return nodes.reduce((matches, node) => { const parent = findParentNode(root, node); // If there's no parent, there's no siblings if (!parent) { return matches; } const parentChildren = childrenOfNode(parent); const nodeIndex = parentChildren.indexOf(node); const adjacentSibling = parentChildren[nodeIndex + 1]; // No sibling if (!adjacentSibling) { return matches; } if (predicate(adjacentSibling)) { matches.push(adjacentSibling); } return matches; }, []); } /** * Matches only nodes which are general siblings (any sibling *after*) * against a predicate, returning those that match. * @param {Array<Node>} nodes * @param {Function} predicate * @param {Node} root */ function matchGeneralSibling(nodes, predicate, root) { return uniqueReduce((matches, node) => { const parent = findParentNode(root, node); if (!parent) { return matches; } const parentChildren = childrenOfNode(parent); const nodeIndex = parentChildren.indexOf(node); const youngerSiblings = parentChildren.slice(nodeIndex + 1); return matches.concat(youngerSiblings.filter(predicate)); }, nodes); } /** * Matches only nodes which are direct children (not grandchildren, etc.) * against a predicate, returning those that match. * @param {Array<Node>} nodes * @param {Function} predicate */ function matchDirectChild(nodes, predicate) { return uniqueReduce( (matches, node) => matches.concat(childrenOfNode(node).filter(predicate)), nodes, ); } /** * Matches all descendant nodes against a predicate, * returning those that match. * @param {Array<Node>} nodes * @param {Function} predicate */ function matchDescendant(nodes, predicate) { return uniqueReduce( (matches, node) => matches.concat(treeFilter(node, predicate)), flat(nodes.map(childrenOfNode)), ); } /** * Takes an RST and reduces it to a set of nodes matching * the selector. The selector can be a simple selector, which * is handled by `buildPredicate`, or a complex CSS selector which * reduceTreeBySelector parses and reduces the tree based on the combinators. * * @param {EnzymeSelector} selector * @param {RSTNode} root */ export function reduceTreeBySelector(selector, root) { if (typeof selector !== 'string') { const elements = elementsByConstructor(selector); if (elements.length > 0) { return flat(elements.map((x) => reduceTreeBySelector(x.tag, root))); // when https://github.com/aweary/rst-selector-parser/issues/15 is resolved // const htmlTagNames = elements.map(x => x.tag).join(', '); // return reduceTreeBySelector(htmlTagNames, root); } } if (typeof selector === 'function' || typeof selector === 'object') { return treeFilter(root, buildPredicate(selector)); } let results = []; if (typeof selector === 'string') { const tokens = safelyGenerateTokens(selector); let index = 0; while (index < tokens.length) { const token = tokens[index]; /** * There are two types of tokens in a CSS selector: * * 1. Selector tokens. These target nodes directly, like * type or attribute selectors. These are easy to apply * because we can traverse the tree and return only * the nodes that match the predicate. * * 2. Combinator tokens. These tokens chain together * selector nodes. For example > for children, or + * for adjacent siblings. These are harder to match * as we have to track where in the tree we are * to determine if a selector node applies or not. */ if (token.type === SELECTOR) { const predicate = buildPredicateFromToken(token, root); results = results.concat(treeFilter(root, predicate)); } else { // We can assume there always all previously matched tokens since selectors // cannot start with combinators. const { type } = token; // We assume the next token is a selector, so move the index // forward and build the predicate. index += 1; const predicate = buildPredicateFromToken(tokens[index], root); // We match against only the nodes which have already been matched, // since a combinator is meant to refine a previous selector. switch (type) { // The + combinator case ADJACENT_SIBLING: results = matchAdjacentSiblings(results, predicate, root); break; // The ~ combinator case GENERAL_SIBLING: results = matchGeneralSibling(results, predicate, root); break; // The > combinator case CHILD: results = matchDirectChild(results, predicate); break; // The ' ' (whitespace) combinator case DESCENDANT: { results = matchDescendant(results, predicate); break; } default: throw new Error(`Unknown combinator selector: ${type}`); } } index += 1; } } else { throw new TypeError('Enzyme::Selector expects a string, object, or Component Constructor'); } return results; } export function reduceTreesBySelector(selector, roots) { const results = roots.map((n) => reduceTreeBySelector(selector, n)); return unique(flat(results, 1)); }