UNPKG

enzyme

Version:

JavaScript Testing utilities for React

434 lines (364 loc) 12.4 kB
/* eslint no-use-before-define:0 */ import isEqual from 'lodash/isEqual'; import React from 'react'; import is from 'object-is'; import uuidv4 from 'uuid/v4'; import entries from 'object.entries'; import functionName from 'function.prototype.name'; import { isDOMComponent, findDOMNode, childrenToArray, } from './react-compat'; import { REACT013, REACT15, } from './version'; export const ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; function internalInstanceKey(node) { return Object.keys(Object(node)).filter(key => key.match(/^__reactInternalInstance\$/))[0]; } export function internalInstance(inst) { return inst._reactInternalInstance || inst[internalInstanceKey(inst)]; } export function isFunctionalComponent(inst) { return !!inst && !!inst.constructor && typeof inst.constructor === 'function' && functionName(inst.constructor) === 'StatelessComponent'; } export function isCustomComponentElement(inst) { return !!inst && React.isValidElement(inst) && typeof inst.type === 'function'; } export function propsOfNode(node) { if (REACT013 && node && node._store) { return (node._store.props) || {}; } if (node && node._reactInternalComponent && node._reactInternalComponent._currentElement) { return (node._reactInternalComponent._currentElement.props) || {}; } if (node && node._currentElement) { return (node._currentElement.props) || {}; } if (REACT15 && node) { if (internalInstance(node) && internalInstance(node)._currentElement) { return (internalInstance(node)._currentElement.props) || {}; } } return (node && node.props) || {}; } export function typeOfNode(node) { return node ? node.type : null; } export function getNode(node) { return isDOMComponent(node) ? findDOMNode(node) : node; } export function nodeHasType(node, type) { if (!type || !node) return false; if (!node.type) return false; if (typeof node.type === 'string') return node.type === type; return (typeof node.type === 'function' ? functionName(node.type) === type : node.type.name === type) || node.type.displayName === type; } function internalChildrenCompare(a, b, lenComp, isLoose) { const nodeCompare = isLoose ? nodeMatches : nodeEqual; if (a === b) return true; if (!Array.isArray(a) && !Array.isArray(b)) { return nodeCompare(a, b, lenComp); } if (!a && !b) return true; if (a.length !== b.length) return false; if (a.length === 0 && b.length === 0) return true; for (let i = 0; i < a.length; i += 1) { if (!nodeCompare(a[i], b[i], lenComp)) return false; } return true; } export function childrenMatch(a, b, lenComp) { return internalChildrenCompare(a, b, lenComp, true); } export function childrenEqual(a, b, lenComp) { return internalChildrenCompare(a, b, lenComp, false); } function removeNullaryReducer(acc, [key, value]) { const addition = value == null ? {} : { [key]: value }; return { ...acc, ...addition }; } function internalNodeCompare(a, b, lenComp, isLoose) { if (a === b) return true; if (!a || !b) return false; if (a.type !== b.type) return false; let left = propsOfNode(a); let right = propsOfNode(b); if (isLoose) { left = entries(left).reduce(removeNullaryReducer, {}); right = entries(right).reduce(removeNullaryReducer, {}); } const leftKeys = Object.keys(left); for (let i = 0; i < leftKeys.length; i += 1) { const prop = leftKeys[i]; // we will check children later if (prop === 'children') { // continue; } else if (!(prop in right)) { return false; } else if (right[prop] === left[prop]) { // continue; } else if (typeof right[prop] === typeof left[prop] && typeof left[prop] === 'object') { if (!isEqual(left[prop], right[prop])) return false; } else { return false; } } const leftHasChildren = 'children' in left; const rightHasChildren = 'children' in right; const childCompare = isLoose ? childrenMatch : childrenEqual; if (leftHasChildren || rightHasChildren) { if (!childCompare( childrenToSimplifiedArray(left.children), childrenToSimplifiedArray(right.children), lenComp, )) { return false; } } if (!isTextualNode(a)) { const rightKeys = Object.keys(right); return lenComp(leftKeys.length - leftHasChildren, rightKeys.length - rightHasChildren); } return false; } export function nodeMatches(a, b, lenComp = is) { return internalNodeCompare(a, b, lenComp, true); } export function nodeEqual(a, b, lenComp = is) { return internalNodeCompare(a, b, lenComp, false); } export function containsChildrenSubArray(match, node, subArray) { const children = childrenOfNode(node); const checker = (_, i) => arraysEqual(match, children.slice(i, i + subArray.length), subArray); return children.some(checker); } function arraysEqual(match, left, right) { return left.length === right.length && left.every((el, i) => match(el, right[i])); } export function childrenToSimplifiedArray(nodeChildren) { const childrenArray = childrenToArray(nodeChildren); const simplifiedArray = []; for (let i = 0; i < childrenArray.length; i += 1) { const child = childrenArray[i]; const previousChild = simplifiedArray.pop(); if (previousChild === undefined) { simplifiedArray.push(child); } else if (isTextualNode(child) && isTextualNode(previousChild)) { simplifiedArray.push(previousChild + child); } else { simplifiedArray.push(previousChild); simplifiedArray.push(child); } } return simplifiedArray; } function childrenOfNode(node) { const props = propsOfNode(node); const { children } = props; return childrenToArray(children); } function isTextualNode(node) { return typeof node === 'string' || typeof node === 'number'; } export function isReactElementAlike(arg) { return React.isValidElement(arg) || isTextualNode(arg) || Array.isArray(arg); } // 'click' => 'onClick' // 'mouseEnter' => 'onMouseEnter' export function propFromEvent(event) { const nativeEvent = mapNativeEventNames(event); return `on${nativeEvent[0].toUpperCase()}${nativeEvent.slice(1)}`; } export function withSetStateAllowed(fn) { // NOTE(lmr): // this is currently here to circumvent a React bug where `setState()` is // not allowed without global being defined. let cleanup = false; if (typeof global.document === 'undefined') { cleanup = true; global.document = {}; } fn(); if (cleanup) { // This works around a bug in node/jest in that developers aren't able to // delete things from global when running in a node vm. global.document = undefined; delete global.document; } } export function splitSelector(selector) { // step 1: make a map of all quoted strings with a uuid const quotedSegments = selector.split(/[^" ]+|("[^"]*")|.*/g) .filter(Boolean) .reduce((obj, match) => ({ ...obj, [match]: uuidv4() }), {}); const splits = selector // step 2: replace all quoted strings with the uuid, so we don't have to properly parse them .replace(/[^" ]+|("[^"]*")|.*/g, x => quotedSegments[x] || x) // step 3: split as best we can without a proper parser .split(/(?=\.|\[.*])|(?=#|\[#.*])/) // step 4: restore the quoted strings by swapping back the uuid's for the original segments .map((selectorSegment) => { let restoredSegment = selectorSegment; entries(quotedSegments).forEach(([k, v]) => { restoredSegment = restoredSegment.replace(v, k); }); return restoredSegment; }); if (splits.length === 1 && splits[0] === selector) { // splitSelector expects selector to be "splittable" throw new TypeError('Enzyme::Selector received what appears to be a malformed string selector'); } return splits; } const containsQuotes = /'|"/; const containsColon = /:/; export function isPseudoClassSelector(selector) { if (containsColon.test(selector)) { if (!containsQuotes.test(selector)) { return true; } const tokens = selector.split(containsQuotes); return tokens.some((token, i) => containsColon.test(token) && i % 2 === 0, ); } return false; } export function selectorError(selector, type = '') { return new TypeError( `Enzyme received a ${type} CSS selector ('${selector}') that it does not currently support`, ); } export const isCompoundSelector = /^[.#]?-?[_a-z]+[_a-z0-9-]*[.[#]/i; const isPropSelector = /^\[.*]$/; export const SELECTOR = { CLASS_TYPE: 0, ID_TYPE: 1, PROP_TYPE: 2, }; export function selectorType(selector) { if (isPseudoClassSelector(selector)) { throw selectorError(selector, 'pseudo-class'); } if (selector[0] === '.') { return SELECTOR.CLASS_TYPE; } else if (selector[0] === '#') { return SELECTOR.ID_TYPE; } else if (isPropSelector.test(selector)) { return SELECTOR.PROP_TYPE; } return undefined; } export function AND(fns) { const fnsReversed = fns.slice().reverse(); return x => fnsReversed.every(fn => fn(x)); } export function coercePropValue(propName, propValue) { // can be undefined if (propValue === undefined) { return propValue; } // can be the empty string if (propValue === '') { return propValue; } if (propValue === 'NaN') { return NaN; } if (propValue === 'null') { return null; } const trimmedValue = propValue.trim(); // if propValue includes quotes, it should be // treated as a string // eslint override pending https://github.com/eslint/eslint/issues/7472 // eslint-disable-next-line no-useless-escape if (/^(['"]).*\1$/.test(trimmedValue)) { return trimmedValue.slice(1, -1); } const numericPropValue = +trimmedValue; // if parseInt is not NaN, then we've wanted a number if (!is(NaN, numericPropValue)) { return numericPropValue; } // coerce to boolean if (trimmedValue === 'true') return true; if (trimmedValue === 'false') return false; // user provided an unquoted string value throw new TypeError( `Enzyme::Unable to parse selector '[${propName}=${propValue}]'. ` + `Perhaps you forgot to escape a string? Try '[${propName}="${trimmedValue}"]' instead.`, ); } export function nodeHasProperty(node, propKey, stringifiedPropValue) { const nodeProps = propsOfNode(node); const descriptor = Object.getOwnPropertyDescriptor(nodeProps, propKey); if (descriptor && descriptor.get) { return false; } const nodePropValue = nodeProps[propKey]; const propValue = coercePropValue(propKey, stringifiedPropValue); if (nodePropValue === undefined) { return false; } if (propValue !== undefined) { return is(nodePropValue, propValue); } return Object.prototype.hasOwnProperty.call(nodeProps, propKey); } export function mapNativeEventNames(event) { const nativeToReactEventMap = { compositionend: 'compositionEnd', compositionstart: 'compositionStart', compositionupdate: 'compositionUpdate', keydown: 'keyDown', keyup: 'keyUp', keypress: 'keyPress', contextmenu: 'contextMenu', dblclick: 'doubleClick', doubleclick: 'doubleClick', // kept for legacy. TODO: remove with next major. dragend: 'dragEnd', dragenter: 'dragEnter', dragexist: 'dragExit', dragleave: 'dragLeave', dragover: 'dragOver', dragstart: 'dragStart', mousedown: 'mouseDown', mousemove: 'mouseMove', mouseout: 'mouseOut', mouseover: 'mouseOver', mouseup: 'mouseUp', touchcancel: 'touchCancel', touchend: 'touchEnd', touchmove: 'touchMove', touchstart: 'touchStart', canplay: 'canPlay', canplaythrough: 'canPlayThrough', durationchange: 'durationChange', loadeddata: 'loadedData', loadedmetadata: 'loadedMetadata', loadstart: 'loadStart', ratechange: 'rateChange', timeupdate: 'timeUpdate', volumechange: 'volumeChange', beforeinput: 'beforeInput', }; if (!REACT013) { // these could not be simulated in React 0.13: // https://github.com/facebook/react/issues/1297 nativeToReactEventMap.mouseenter = 'mouseEnter'; nativeToReactEventMap.mouseleave = 'mouseLeave'; } return nativeToReactEventMap[event] || event; } export function displayNameOfNode(node) { const { type } = node; if (!type) return null; return type.displayName || (typeof type === 'function' ? functionName(type) : type.name || type); }