@primer/components
Version:
Primer react components
246 lines (220 loc) • 6.89 kB
JavaScript
import React from 'react';
import { promisify } from 'util';
import renderer from 'react-test-renderer';
import enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import { cleanup, render as HTMLRender } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { ThemeProvider } from '..';
import { default as defaultTheme } from '../theme';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const readFile = promisify(require('fs').readFile);
export const COMPONENT_DISPLAY_NAME_REGEX = /^[A-Z][A-Za-z]+(\.[A-Z][A-Za-z]+)*$/;
enzyme.configure({
adapter: new Adapter()
});
export function mount(component) {
return enzyme.mount(component);
}
/**
* Render the component (a React.createElement() or JSX expression)
* into its intermediate object representation with 'type',
* 'props', and 'children' keys
*
* The returned object can be matched with expect().toEqual(), e.g.
*
* ```js
* expect(render(<Foo />)).toEqual(render(<div foo='bar' />))
* ```
*/
export function render(component, theme = defaultTheme) {
return renderer.create( /*#__PURE__*/React.createElement(ThemeProvider, {
theme: theme
}, component)).toJSON();
}
/**
* Render the component (a React.createElement() or JSX expression)
* using react-test-renderer and return the root node
* ```
*/
export function renderRoot(component) {
return renderer.create(component).root;
}
/**
* Get the HTML class names rendered by the component instance
* as an array.
*
* ```js
* expect(renderClasses(<div className='a b' />))
* .toEqual(['a', 'b'])
* ```
*/
export function renderClasses(component) {
const {
props: {
className
}
} = render(component);
return className ? className.trim().split(' ') : [];
}
/**
* Returns true if a node renders with a single class.
*/
export function rendersClass(node, klass) {
return renderClasses(node).includes(klass);
}
export function px(value) {
return typeof value === 'number' ? `${value}px` : value;
}
export function percent(value) {
return typeof value === 'number' ? `${value}%` : value;
}
export function renderStyles(node) {
const {
props: {
className
}
} = render(node);
return getComputedStyles(className);
}
export function getComputedStyles(className) {
const div = document.createElement('div');
div.className = className;
const computed = {};
for (const sheet of document.styleSheets) {
// CSSRulesLists assumes every rule is a CSSRule, not a CSSStyleRule
for (const rule of sheet.cssRules) {
if (rule instanceof CSSMediaRule) {
readMedia(rule);
} else if (rule instanceof CSSStyleRule) {
readRule(rule, computed);
} else {// console.warn('rule.type =', rule.type)
}
}
}
return computed;
function matchesSafe(node, selector) {
if (!selector) {
return false;
}
try {
return node.matches(selector);
} catch (error) {
return false;
}
}
function readRule(rule, dest) {
if (matchesSafe(div, rule.selectorText)) {
const {
style
} = rule;
for (let i = 0; i < style.length; i++) {
const prop = style[i];
dest[prop] = style.getPropertyValue(prop);
}
} else {// console.warn('no match:', rule.selectorText)
}
}
function readMedia(mediaRule) {
const key = `@media ${mediaRule.media[0]}`; // const dest = computed[key] || (computed[key] = {})
const dest = {};
for (const rule of mediaRule.cssRules) {
if (rule instanceof CSSStyleRule) {
readRule(rule, dest);
}
} // Don't add media rule to computed styles
// if no styles were actually applied
if (Object.keys(dest).length > 0) {
computed[key] = dest;
}
}
}
/**
* This provides a layer of compatibility between the render() function from
* react-test-renderer and Enzyme's mount()
*/
export function getProps(node) {
return typeof node.props === 'function' ? node.props() : node.props;
}
export function getClassName(node) {
return getProps(node).className;
}
export function getClasses(node) {
const className = getClassName(node);
return className ? className.trim().split(/ +/) : [];
}
export async function loadCSS(path) {
const css = await readFile(require.resolve(path), 'utf8');
const style = document.createElement('style');
style.setAttribute('data-path', path);
style.textContent = css;
document.head.appendChild(style);
return style;
}
export function unloadCSS(path) {
const style = document.querySelector(`style[data-path="${path}"]`);
if (style) {
style.remove();
return true;
}
} // If a component requires certain props or other conditions in order
// to render without errors, you can pass a `toRender` function that
// returns an element ready to be rendered.
export function behavesAsComponent({
Component,
toRender,
options
}) {
options = options || {};
const getElement = () => toRender ? toRender() : /*#__PURE__*/React.createElement(Component, null);
if (!options.skipSx) {
it('implements sx prop behavior', () => {
expect(getElement()).toImplementSxBehavior();
});
}
if (!options.skipAs) {
it('respects the as prop', () => {
const As = /*#__PURE__*/React.forwardRef((_props, ref) => /*#__PURE__*/React.createElement("div", {
className: "as-component",
ref: ref
}));
const elem = /*#__PURE__*/React.cloneElement(getElement(), {
as: As
});
expect(render(elem)).toEqual(render( /*#__PURE__*/React.createElement(As, null)));
});
}
it('sets a valid displayName', () => {
expect(Component.displayName).toMatch(COMPONENT_DISPLAY_NAME_REGEX);
});
it('renders consistently', () => {
expect(render(getElement())).toMatchSnapshot();
});
} // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function checkExports(path, exports) {
it('has declared exports', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(`../${path}`);
expect(mod).toSetExports(exports);
});
}
expect.extend(toHaveNoViolations);
export function checkStoriesForAxeViolations(name) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const stories = require(`../stories/${name}.stories`); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- _meta
const {
default: _meta,
...Stories
} = stories;
Object.values(Stories).map(Story => {
if (typeof Story !== 'function') return;
it(`story ${Story.storyName} should have no axe violations`, async () => {
const {
container
} = HTMLRender( /*#__PURE__*/React.createElement(Story, null));
const results = await axe(container);
expect(results).toHaveNoViolations();
cleanup();
});
});
}