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: