UNPKG

@dash-ui/jest

Version:

Jest utilities for dash-ui

396 lines (333 loc) 10.9 kB
import * as css from 'css' import chalk from 'chalk' // // Utils const flatMap = (arr, iteratee) => [].concat(...arr.map(iteratee)) export const RULE_TYPES = { media: 'media', supports: 'supports', rule: 'rule', } const getClassNames = (selectors, classes) => classes ? selectors.concat(classes.split(' ')) : selectors const getClassNamesFromTestRenderer = (selectors, {props}) => getClassNames(selectors, props ? props.className || props.class : null) const shouldDive = (node) => typeof node.dive === 'function' && typeof node.type() !== 'string' const isTagWithClassName = (node) => node.prop('className') && typeof node.type() === 'string' const getClassNamesFromEnzyme = (selectors, node) => { // We need to dive if we have selected a styled child from a shallow render const actualComponent = shouldDive(node) ? node.dive() : node // Find the first node with a className prop const components = actualComponent.findWhere(isTagWithClassName) const classes = components.length && components.first().prop('className') return getClassNames(selectors, classes) } const getClassNamesFromCheerio = (selectors, node) => { const classes = node.attr('class') return getClassNames(selectors, classes) } const getClassNamesFromDOMElement = (selectors, node) => getClassNames(selectors, node.getAttribute('class')) export const isReactElement = (val) => { if (val.$$typeof === Symbol.for('react.test.json')) { return true } else if ( val.hasOwnProperty('props') && val.hasOwnProperty('type') && val.hasOwnProperty('ref') && val.hasOwnProperty('key') ) { // Preact X val.$$typeof = Symbol.for('react.test.json') return true } } const domElementPattern = /^((HTML|SVG)\w*)?Element$/ export const isDOMElement = (val) => val.nodeType === 1 && val.constructor && val.constructor.name && domElementPattern.test(val.constructor.name) const isEnzymeElement = (val) => typeof val.findWhere === 'function' const isCheerioElement = (val) => val.cheerio === '[cheerio object]' export const getClassNamesFromNodes = (nodes) => nodes.reduce((selectors, node) => { if (isReactElement(node)) { return getClassNamesFromTestRenderer(selectors, node) } else if (isEnzymeElement(node)) { return getClassNamesFromEnzyme(selectors, node) } else if (isCheerioElement(node)) { return getClassNamesFromCheerio(selectors, node) } return getClassNamesFromDOMElement(selectors, node) }, []) let keyframesPattern = /^@keyframes\s+(animation-[^{\s]+)+/ let removeCommentPattern = /\/\*[\s\S]*?\*\//g const getElementRules = (element) => { const nonSpeedyRule = element.textContent if (nonSpeedyRule) { return [nonSpeedyRule] } if (!element.sheet) { return [] } // $FlowFixMe - flow doesn't know about `cssRules` property return [].slice.call(element.sheet.cssRules).map((cssRule) => cssRule.cssText) } export const getStylesFromClassNames = (classNames, elements) => { if (!classNames.length) { return '' } let keys = getKeys(elements) if (!keys.length) { return '' } let keyPatten = new RegExp(`^(${keys.join('|')})-`) let filteredClassNames = classNames.filter((className) => keyPatten.test(className) ) if (!filteredClassNames.length) { return '' } let selectorPattern = new RegExp('\\.(' + filteredClassNames.join('|') + ')') let keyframes = {} let styles = '' flatMap(elements, getElementRules).forEach((rule) => { if (selectorPattern.test(rule)) { styles += rule } let match = rule.match(keyframesPattern) if (match !== null) { let name = match[1] if (keyframes[name] === undefined) { keyframes[name] = '' } keyframes[name] += rule } }) let keyframeNameKeys = Object.keys(keyframes) let keyframesStyles = '' if (keyframeNameKeys.length) { let keyframesNamePattern = new RegExp(keyframeNameKeys.join('|'), 'g') let keyframesNameCache = {} let index = 0 styles = styles.replace(keyframesNamePattern, (name) => { if (keyframesNameCache[name] === undefined) { keyframesNameCache[name] = `animation-${index++}` keyframesStyles += keyframes[name] } return keyframesNameCache[name] }) keyframesStyles = keyframesStyles.replace(keyframesNamePattern, (value) => { return keyframesNameCache[value] }) } return (keyframesStyles + styles).replace(removeCommentPattern, '') } export const getStyleElements = () => Array.from(document.querySelectorAll('style[data-dash]')) let unique = (arr) => Array.from(new Set(arr)) export const getKeys = (elements) => unique(elements.map((element) => element.getAttribute('data-dash'))).filter( Boolean ) export const hasClassNames = (classNames, selectors, target) => selectors.some((selector) => { // if no target, use className of the specific css rule and try to find it // in the list of received node classNames to make sure this css rule // applied for root element if (!target) { return classNames.includes(selector.slice(1)) } // check if selector (className) of specific css rule match target return target instanceof RegExp ? target.test(selector) : minify(selector).includes(minify(target)) }) export const getMediaRules = (rules, media) => rules .filter((rule) => { const isMediaMatch = rule.media ? rule.media.replace(/\s/g, '').includes(media.replace(/\s/g, '')) : false return rule.type === RULE_TYPES.media && isMediaMatch }) .reduce((mediaRules, mediaRule) => mediaRules.concat(mediaRule.rules), []) export const getSupportsRules = (rules, supports) => rules .filter((rule) => { const isSupportsMatch = rule.supports ? rule.supports .replace(/\s/g, '') .trim() .endsWith(supports.replace(/\s/g, '').trim()) : false return rule.type === RULE_TYPES.supports && isSupportsMatch }) .reduce( (supportsRules, supportsRule) => supportsRules.concat(supportsRule.rules), [] ) // // Matchers /* * Taken from * https://github.com/facebook/jest/blob/be4bec387d90ac8d6a7596be88bf8e4994bc3ed9/packages/expect/src/jasmine_utils.js#L234 */ const isA = (typeName, value) => Object.prototype.toString.apply(value) === `[object ${typeName}]` /* * Taken from * https://github.com/facebook/jest/blob/be4bec387d90ac8d6a7596be88bf8e4994bc3ed9/packages/expect/src/jasmine_utils.js#L36 */ const isAsymmetric = (obj) => obj && isA('Function', obj.asymmetricMatch) const valueMatches = (declaration, value) => { if (value instanceof RegExp) { return value.test(declaration.value) } if (isAsymmetric(value)) { return value.asymmetricMatch(declaration.value) } return minify(value) === minify(declaration.value) } const minLeft = /([:;,([{}>~/\s]|\/\*)\s+/g const minRight = /\s+([:;,)\]{}>~/!]|\*\/)/g const minify = (s) => s.trim().replace(minLeft, '$1').replace(minRight, '$1') const toHaveStyleRule = (received, property, value, options = {}) => { const {target, media, supports} = options const classNames = getClassNamesFromNodes([received]) const cssString = getStylesFromClassNames(classNames, getStyleElements()) const styles = css.parse(cssString) let preparedRules = styles.stylesheet.rules if (media) { preparedRules = getMediaRules(preparedRules, media) } if (supports) { preparedRules = getSupportsRules(preparedRules, supports) } const declaration = preparedRules .filter( (rule) => rule.type === RULE_TYPES.rule && hasClassNames(classNames, rule.selectors, target) ) .reduce((decs, rule) => decs.concat(rule.declarations), []) .filter( (dec) => dec.type === 'declaration' && minify(dec.property) === minify(property) ) .pop() if (!declaration) { return { pass: false, message: () => `Property not found: ${property}`, } } const pass = valueMatches(declaration, value) const message = () => `Expected ${property}${pass ? ' not ' : ' '}to match:\n` + ` ${chalk.green(value)}\n` + 'Received:\n' + ` ${chalk.red(declaration.value)}` return { pass, message, } } export const matchers = {toHaveStyleRule} // // Pretty serialization const defaultClassNameReplacer = (className, index) => `dash-ui-${index}` const componentSelectorClassNamePattern = /^e[a-zA-Z0-9]+[0-9]+$/ export const replaceClassNames = ( classNames, styles, code, keys, classNameReplacer = defaultClassNameReplacer ) => { let index = 0 let keyPattern = new RegExp(`^(${keys.join('|')})-`) return classNames.reduce((acc, className) => { if ( keyPattern.test(className) || componentSelectorClassNamePattern.test(className) ) { const escapedRegex = new RegExp( className.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'), 'g' ) return acc.replace(escapedRegex, classNameReplacer(className, index++)) } return acc }, `${styles}${styles ? '\n\n' : ''}${code}`) } const getNodes = (node, nodes = []) => { if (Array.isArray(node)) { for (let child of node) { getNodes(child, nodes) } return nodes } let children = node.children || (node.props && node.props.children) if (children) { // fix for Preact X children = node.props ? Array.isArray(children) ? children : [children] : children for (let child of children) { getNodes(child, nodes) } } if (typeof node === 'object') { nodes.push(node) } return nodes } const getPrettyStylesFromClassNames = (classNames, elements) => { let styles = getStylesFromClassNames(classNames, elements) let prettyStyles try { prettyStyles = css.stringify(css.parse(styles)) } catch (e) { console.error(e) throw new Error(`There was an error parsing the following css: "${styles}"`) } return prettyStyles } export const createSerializer = (opt = {}) => { let {classNameReplacer, DOMElements = true} = opt let cache = new WeakSet() return { test(val) { return ( val && !cache.has(val) && (isReactElement(val) || (DOMElements && isDOMElement(val))) ) }, print(val, printer) { const nodes = getNodes(val) const classNames = getClassNamesFromNodes(nodes) let elements = getStyleElements() const styles = getPrettyStylesFromClassNames(classNames, elements) nodes.forEach(cache.add, cache) const printedVal = printer(val) nodes.forEach(cache.delete, cache) let keys = getKeys(elements) return replaceClassNames( classNames, styles, printedVal, keys, classNameReplacer ) }, } } export const {print, test} = createSerializer() export default {print, test}