diff --git a/packages/jest/package.json b/packages/jest/package.json index 4307e0629..b861610e5 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -18,5 +18,12 @@ "files": [ "dist", "src" - ] + ], + "dependencies": { + "css": "^2.2.4" + }, + "devDependencies": { + "@types/css": "^0.0.31", + "csstype": "^2.6.10" + } } diff --git a/packages/jest/src/__tests__/matchers.test.tsx b/packages/jest/src/__tests__/matchers.test.tsx index 1abd7bd6c..70f043714 100644 --- a/packages/jest/src/__tests__/matchers.test.tsx +++ b/packages/jest/src/__tests__/matchers.test.tsx @@ -87,4 +87,116 @@ describe('toHaveCompliedCss', () => { color: 'blue', }); }); + + it('should match styles with target:hover', () => { + const { getByText } = render( +
+ hello world +
+ ); + const el = getByText('hello world'); + expect(el).toHaveCompiledCss('transform', 'scale(2)', { target: ':hover' }); + expect(el).not.toHaveCompiledCss('transform', 'scale(2)'); + }); + + it('should match styles with target', () => { + const { getByText } = render( +
+ hello world +
+ ); + const el = getByText('hello world'); + expect(el).not.toHaveCompiledCss('color', 'blue', { target: ':hover' }); + expect(el).not.toHaveCompiledCss('transform', 'scale(2)'); + expect(el).not.toHaveCompiledCss('transform', 'scale(2)', { target: ':active' }); + expect(el).toHaveCompiledCss('transform', 'scale(2)', { target: ':hover' }); + expect(el).toHaveCompiledCss('color', 'blue', { target: ':active' }); + }); + + it('should match styles with media', () => { + const { getByText } = render( +
+ hello world +
+ ); + const el = getByText('hello world'); + expect(el).toHaveCompiledCss('color', 'green'); + expect(el).toHaveCompiledCss('color', 'yellow', { media: 'screen' }); + // without narrowing to media -> screen + expect(el).not.toHaveCompiledCss('color', 'yellow'); + }); + + it('should match styles with media and target', () => { + const { getByText } = render( +
+ hello world +
+ ); + const el = getByText('hello world'); + expect(el).toHaveCompiledCss('background', 'red', { media: 'screen', target: ':hover' }); + }); + + it('should match styles with media nested inside class', () => { + const { getByText } = render( +
+ hello world +
+ ); + const el = getByText('hello world'); + expect(el).toHaveCompiledCss('color', 'blue', { media: '(min-width: 2px)' }); + expect(el).toHaveCompiledCss('color', 'red', { media: '(min-width: 1px)' }); + }); + + it('should match complicated direct ancestors', () => { + const { getByText } = render( +
:first-child { + color: red; + } + `}> + hello world +
+ ); + const el = getByText('hello world'); + expect(el).toHaveCompiledCss('color', 'red', { target: '> :first-child' }); + }); }); diff --git a/packages/jest/src/index.tsx b/packages/jest/src/index.tsx index 8f69ff4ab..68e4296da 100644 --- a/packages/jest/src/index.tsx +++ b/packages/jest/src/index.tsx @@ -1,8 +1,10 @@ +import { MatchFilter } from './types'; + declare global { namespace jest { interface Matchers { - toHaveCompiledCss(properties: { [key: string]: string }): R; - toHaveCompiledCss(property: string, value: string): R; + toHaveCompiledCss(properties: { [key: string]: string }, matchFilter?: MatchFilter): R; + toHaveCompiledCss(property: string, value: string, matchFilter?: MatchFilter): R; } } } diff --git a/packages/jest/src/matchers.tsx b/packages/jest/src/matchers.tsx index 829630aa1..33585001e 100644 --- a/packages/jest/src/matchers.tsx +++ b/packages/jest/src/matchers.tsx @@ -1,9 +1,18 @@ +import CSS, { StyleRules, Media } from 'css'; +import { MatchFilter } from './types'; + +type Arg = [{ [key: string]: string }, MatchFilter?]; + +const DEFAULT_MATCH_FILTER: MatchFilter = { media: undefined, target: undefined }; + const kebabCase = (str: string) => str .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/\s+/g, '-') .toLowerCase(); +const removeSpaces = (str?: string) => str && str.replace(/\s/g, ''); + const mapProperties = (properties: Record) => Object.keys(properties).map(property => `${kebabCase(property)}:${properties[property]}`); @@ -15,15 +24,70 @@ const getMountedProperties = () => ) .join(' '); -const containsClassNames = (classNames: string[], css: string) => - classNames.reduce((accum, className) => (css.includes(`.${className}`) ? true : accum), false); +const onlyRules = (rules?: StyleRules['rules']) => rules?.filter(r => r.type === 'rule'); + +const findMediaRules = ( + allRules: StyleRules['rules'] = [], + media: string +): Media['rules'] | undefined => { + for (const rule of allRules) { + if (!rule) return; + if ('media' in rule) { + if (removeSpaces(rule.media) === removeSpaces(media) && 'rules' in rule) return rule.rules; + if ('rules' in rule) return findMediaRules(rule.rules, media); + } + } + return; +}; + +const getRules = (ast: CSS.Stylesheet, filter: MatchFilter, className: string) => { + const { media, target } = filter; + + // rules are present directly inside ast.stylesheet.rules + // but if a media query is present it is nested inside ast.stylesheet.media.rules + // this inner function returns the relevant rules + const getAllRules = () => { + if (media) { + return onlyRules(findMediaRules(ast.stylesheet?.rules, media)); + } + return ast.stylesheet?.rules.filter(r => (r.type = 'rule')); // omit media objects + }; + + const allRules = getAllRules(); + const klass = target ? `.${className}${target}` : `.${className}`; + return allRules?.filter(r => { + if ('selectors' in r) { + return r.selectors?.find(s => removeSpaces(s) === removeSpaces(klass)); + } + return; + }); +}; + +const findStylesInRules = (styles: string[], rules: CSS.Rule[] | undefined) => { + const found: string[] = []; + + if (!rules) return found; + + styles.forEach(s => { + rules?.forEach(r => { + if ('declarations' in r) { + r.declarations?.forEach(d => { + if ('property' in d) { + if (s === `${d.property}:${d.value}`) found.push(s); + } + }); + } + }); + }); + return found; +}; export function toHaveCompiledCss( this: jest.MatcherUtils, element: HTMLElement, - ...args: [{ [key: string]: string } | string, string] + ...args: [Arg | string, string, MatchFilter?] ): jest.CustomMatcherResult { - const [property, value] = args; + const [property, value, matchFilter = DEFAULT_MATCH_FILTER] = args; const properties = typeof property === 'string' ? { [property]: value } : property; const inlineStyleTag = element.parentElement && element.parentElement.querySelector('style'); const styleElements: HTMLStyleElement[] = @@ -56,9 +120,11 @@ export function toHaveCompiledCss( }); } - if (containsClassNames(classNames, css)) { - foundStyles.push(...stylesToFind.filter(styleToFind => css.includes(styleToFind))); - } + const ast = CSS.parse(css); + classNames.forEach(c => { + const rules = getRules(ast, matchFilter, c); + foundStyles.push(...findStylesInRules(stylesToFind, rules)); + }); } const notFoundStyles = stylesToFind.filter(style => !foundStyles.includes(style)); diff --git a/packages/jest/src/types.tsx b/packages/jest/src/types.tsx new file mode 100644 index 000000000..9fcc04510 --- /dev/null +++ b/packages/jest/src/types.tsx @@ -0,0 +1,14 @@ +import { Pseudos } from 'csstype'; + +/** + * {} is used to enabled the compiler to not reduce Target down to a string. + * This keeps both Pseduos intellisense working as well as then allowing us to define anything. + * See: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-460346421 + */ +type AnyTarget = string & { _?: never }; +type Target = Pseudos | AnyTarget; + +export interface MatchFilter { + target?: Target; + media?: string; +} diff --git a/yarn.lock b/yarn.lock index 954719116..12e1761b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1730,6 +1730,11 @@ resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.1.tgz#d4d180dd6adc5cb68ad99bd56e03d637881f4616" integrity sha512-laiDIXqqthjJlyAMYAXOtN3N8+UlbM+KvZi4BaY5ZOekmVkBs/UxfK5O0HWeJVG2eW8F+Mu2ww13fTX+kY1FlQ== +"@types/css@^0.0.31": + version "0.0.31" + resolved "https://registry.yarnpkg.com/@types/css/-/css-0.0.31.tgz#235cf80e4991a9d769fd640b4b8644b0a4139895" + integrity sha512-Xt3xp8o0zueqrdIkAOO5gy5YNs+jYfmIUPeoeKiwrcmCRXuNWkIgR2Ph6vHuVfi1y6c9Tx214EQBWPEkU97djw== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -3810,6 +3815,16 @@ css-what@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.2.1.tgz#f4a8f12421064621b456755e34a03a2c22df5da1" +css@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" + integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== + dependencies: + inherits "^2.0.3" + source-map "^0.6.1" + source-map-resolve "^0.5.2" + urix "^0.1.0" + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -3898,7 +3913,7 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" -csstype@^2.0.0, csstype@^2.2.0, csstype@^2.5.7: +csstype@^2.0.0, csstype@^2.2.0, csstype@^2.5.7, csstype@^2.6.10: version "2.6.10" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w== @@ -9552,7 +9567,7 @@ source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" -source-map-resolve@^0.5.0: +source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" dependencies: