Skip to content

Commit

Permalink
feat: add support to assert styles on selectors (hover, active) and m…
Browse files Browse the repository at this point in the history
…edia queries (#168)

* Remove double nesting for matching compiled css

* Add media and state for asserting styles.

* Fix TS error

* Duplicate as TS is erroring

* Add css module

* Refactor matching logic to work on ast with rules.

* Add test for state and media

* Move @types/css to dev dependencies

* Improve tests a bit

* Use `Pseudo` for awesome TS intellisense
Rename state -> target.

* Add support for nested media querie assertions

* Complex direct descendents etc work now.

* Make intellisense work again, thanks @Madou
  • Loading branch information
ankeetmaini authored Apr 26, 2020
1 parent 72e72e5 commit b38c0d8
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 12 deletions.
9 changes: 8 additions & 1 deletion packages/jest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,12 @@
"files": [
"dist",
"src"
]
],
"dependencies": {
"css": "^2.2.4"
},
"devDependencies": {
"@types/css": "^0.0.31",
"csstype": "^2.6.10"
}
}
112 changes: 112 additions & 0 deletions packages/jest/src/__tests__/matchers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,116 @@ describe('toHaveCompliedCss', () => {
color: 'blue',
});
});

it('should match styles with target:hover', () => {
const { getByText } = render(
<div
css={{
fontSize: '12px',
':hover': {
transform: 'scale(2)',
},
}}>
hello world
</div>
);
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(
<div
css={{
fontSize: '12px',
':hover': {
transform: 'scale(2)',
},
':active': {
color: 'blue',
},
}}>
hello world
</div>
);
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(
<div
css={{
color: 'green',
'@media screen': {
color: 'yellow',
},
}}>
hello world
</div>
);
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(
<div
css={{
color: 'green',
'@media screen': {
color: 'yellow',
':hover': {
background: 'red',
},
},
}}>
hello world
</div>
);
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(
<div
css={{
'@media (min-width: 2px)': {
color: 'blue',
'@media (min-width: 1px)': {
color: 'red',
},
},
}}>
hello world
</div>
);
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(
<div
css={`
> :first-child {
color: red;
}
`}>
hello world
</div>
);
const el = getByText('hello world');
expect(el).toHaveCompiledCss('color', 'red', { target: '> :first-child' });
});
});
6 changes: 4 additions & 2 deletions packages/jest/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { MatchFilter } from './types';

declare global {
namespace jest {
interface Matchers<R> {
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;
}
}
}
Expand Down
80 changes: 73 additions & 7 deletions packages/jest/src/matchers.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any>) =>
Object.keys(properties).map(property => `${kebabCase(property)}:${properties[property]}`);

Expand All @@ -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[] =
Expand Down Expand Up @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions packages/jest/src/types.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 17 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit b38c0d8

Please sign in to comment.