diff --git a/src/__tests__/to-have-style.js b/src/__tests__/to-have-style.js index 5991a7e..a8f2fd9 100644 --- a/src/__tests__/to-have-style.js +++ b/src/__tests__/to-have-style.js @@ -215,6 +215,17 @@ describe('.toHaveStyle', () => { }) }) + test('Fails when unit is omitted and the style does not match', () => { + const {queryByTestId} = render(` + Hello World + `) + expect(() => { + expect(queryByTestId('color-example')).toHaveStyle({ + fontSize: 8, + }) + }).toThrow() + }) + test('Fails with an invalid unit', () => { const {queryByTestId} = render(` Hello World @@ -251,4 +262,30 @@ describe('.toHaveStyle', () => { }) }) }) + + describe('Fails when invalid value of property', () => { + test('with empty strings', () => { + const {container} = render(` +
+ `) + + expect(() => + expect(container.querySelector('.border')).toHaveStyle({ + borderWidth: '', + }), + ).toThrow() + }) + + test('with strings without unit', () => { + const {container} = render(` + + `) + + expect(() => { + expect(container.querySelector('.border')).toHaveStyle({ + borderWidth: '2', + }) + }).toThrow() + }) + }) }) diff --git a/src/to-have-style.js b/src/to-have-style.js index 010e2a5..f53a66b 100644 --- a/src/to-have-style.js +++ b/src/to-have-style.js @@ -1,16 +1,126 @@ import chalk from 'chalk' -import {checkHtmlElement, parseCSS} from './utils' +import {checkHtmlElement, parseCSS, camelToKebab} from './utils' + +/** + * Set of CSS properties that typically have unitless values. + * These properties are commonly used in CSS without specifying units such as px, em, etc. + * This set is used to determine whether a numerical value should have a unit appended to it. + * + * Note: This list is based on the `isUnitlessNumber` module from the React DOM package. + * Source: https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/shared/isUnitlessNumber.js + */ +const unitlessNumbers = new Set([ + 'animationIterationCount', + 'aspectRatio', + 'borderImageOutset', + 'borderImageSlice', + 'borderImageWidth', + 'boxFlex', + 'boxFlexGroup', + 'boxOrdinalGroup', + 'columnCount', + 'columns', + 'flex', + 'flexGrow', + 'flexPositive', + 'flexShrink', + 'flexNegative', + 'flexOrder', + 'gridArea', + 'gridRow', + 'gridRowEnd', + 'gridRowSpan', + 'gridRowStart', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnSpan', + 'gridColumnStart', + 'fontWeight', + 'lineClamp', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'scale', + 'tabSize', + 'widows', + 'zIndex', + 'zoom', + 'fillOpacity', // SVG-related properties + 'floodOpacity', + 'stopOpacity', + 'strokeDasharray', + 'strokeDashoffset', + 'strokeMiterlimit', + 'strokeOpacity', + 'strokeWidth', + 'MozAnimationIterationCount', // Known Prefixed Properties + 'MozBoxFlex', // TODO: Remove these since they shouldn't be used in modern code + 'MozBoxFlexGroup', + 'MozLineClamp', + 'msAnimationIterationCount', + 'msFlex', + 'msZoom', + 'msFlexGrow', + 'msFlexNegative', + 'msFlexOrder', + 'msFlexPositive', + 'msFlexShrink', + 'msGridColumn', + 'msGridColumnSpan', + 'msGridRow', + 'msGridRowSpan', + 'WebkitAnimationIterationCount', + 'WebkitBoxFlex', + 'WebKitBoxFlexGroup', + 'WebkitBoxOrdinalGroup', + 'WebkitColumnCount', + 'WebkitColumns', + 'WebkitFlex', + 'WebkitFlexGrow', + 'WebkitFlexPositive', + 'WebkitFlexShrink', + 'WebkitLineClamp', +]) + +function isCustomProperty(property) { + return property.startsWith('--') +} + +function isUnitProperty(property, value) { + if (typeof value !== 'number') { + return false + } + + return !unitlessNumbers.has(property) +} + +/** + * Convert a CSS object to a valid style object. + * This function takes a CSS object and converts it into a valid style object. + * It transforms camelCase property names to kebab-case and appends 'px' unit to numerical values. + */ +function convertCssObject(css) { + return Object.entries(css).reduce((obj, [property, value]) => { + const styleProperty = isCustomProperty(property) + ? property + : camelToKebab(property) + const styleValue = isUnitProperty(property, value) ? `${value}px` : value + return Object.assign(obj, { + [styleProperty]: styleValue, + }) + }, {}) +} function getStyleDeclaration(document, css) { const styles = {} // The next block is necessary to normalize colors const copy = document.createElement('div') - Object.keys(css).forEach(property => { - copy.style[property] = css[property] + Object.entries(css).forEach(([property, value]) => { + copy.style[property] = value styles[property] = copy.style[property] }) - return styles } @@ -18,15 +128,15 @@ function isSubset(styles, computedStyle) { return ( !!Object.keys(styles).length && Object.entries(styles).every(([prop, value]) => { - const isCustomProperty = prop.startsWith('--') const spellingVariants = [prop] - if (!isCustomProperty) spellingVariants.push(prop.toLowerCase()) + if (!isCustomProperty(prop)) spellingVariants.push(prop.toLowerCase()) - return spellingVariants.some( - name => + return spellingVariants.some(name => { + return ( computedStyle[name] === value || - computedStyle.getPropertyValue(name) === value, - ) + computedStyle.getPropertyValue(name) === value + ) + }) }) ) } @@ -57,9 +167,10 @@ export function toHaveStyle(htmlElement, css) { checkHtmlElement(htmlElement, toHaveStyle, this) const parsedCSS = typeof css === 'object' ? css : parseCSS(css, toHaveStyle, this) + const cssObject = convertCssObject(parsedCSS) const {getComputedStyle} = htmlElement.ownerDocument.defaultView - const expected = getStyleDeclaration(htmlElement.ownerDocument, parsedCSS) + const expected = getStyleDeclaration(htmlElement.ownerDocument, cssObject) const received = getComputedStyle(htmlElement) return { diff --git a/src/utils.js b/src/utils.js index 903f24c..1d16295 100644 --- a/src/utils.js +++ b/src/utils.js @@ -227,6 +227,9 @@ function toSentence( array.length > 1 ? lastWordConnector : '', ) } +function camelToKebab(camelCaseString) { + return camelCaseString.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() +} export { HtmlElementTypeError, @@ -242,4 +245,5 @@ export { getSingleElementValue, compareArraysAsSet, toSentence, + camelToKebab, }