diff --git a/package.json b/package.json index 6c787f19b7474a..8841e5a56d6ff1 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@types/prettier": "^2.7.1", "@types/react": "^18.0.21", "@types/react-is": "^17.0.3", + "@types/react-test-renderer": "^18.0.0", "@types/sinon": "^10.0.13", "@types/stylis": "^4.0.2", "@types/webpack": "^5.28.0", diff --git a/test/utils/describeConformance.js b/test/utils/describeConformance.tsx similarity index 80% rename from test/utils/describeConformance.js rename to test/utils/describeConformance.tsx index e2e6cc16a3f2c9..306f3251587856 100644 --- a/test/utils/describeConformance.js +++ b/test/utils/describeConformance.tsx @@ -1,29 +1,71 @@ /* eslint-env mocha */ -import { expect } from 'chai'; import * as React from 'react'; +import { expect } from 'chai'; +import { ReactWrapper } from 'enzyme'; import { ThemeProvider as MDThemeProvider, createTheme } from '@mui/material/styles'; import { unstable_capitalize as capitalize } from '@mui/utils'; import ReactTestRenderer from 'react-test-renderer'; import createMount from './createMount'; import findOutermostIntrinsic from './findOutermostIntrinsic'; +import { MuiRenderResult } from './createRenderer'; + +export interface SlotTestingOptions { + testWithComponent?: React.ComponentType; + testWithElement?: keyof JSX.IntrinsicElements | null; + expectedClassName: string; + isOptional?: boolean; +} + +interface SlotTestOverride { + slotName: string; + slotClassName?: string; +} + +export interface InputConformanceOptions { + muiName: string; + classes: { root: string }; + refInstanceof: any; + after?: () => void; + inheritComponent?: React.ElementType; + render: (node: React.ReactElement) => MuiRenderResult; + only?: Array; + skip?: Array; + testComponentsRootPropWith?: string; + testComponentPropWith?: string; + testDeepOverrides?: SlotTestOverride | SlotTestOverride[]; + testRootOverrides?: SlotTestOverride; + testStateOverrides?: { prop?: string; value?: any; styleKey: string }; + testCustomVariant?: boolean; + testVariantProps?: object; + wrapMount?: ( + mount: (node: React.ReactNode) => ReactWrapper, + ) => (node: React.ReactNode) => ReactWrapper; + slots?: Record; + ThemeProvider?: React.ElementType; +} + +export interface ConformanceOptions extends InputConformanceOptions { + mount: (node: React.ReactNode) => ReactWrapper; +} /** * @param {object} node * @returns */ -function assertDOMNode(node) { +function assertDOMNode(node: unknown) { // duck typing a DOM node - expect(typeof node.nodeName).to.equal('string'); + expect(typeof (node as HTMLElement).nodeName).to.equal('string'); } /** * Utility method to make assertions about the ref on an element - * @param {React.ReactElement} element - The element should have a component wrapped - * in withStyles as the root - * @param {(node: React.ReactNode) => import('enzyme').ReactWrapper} mount - Should be returnvalue of createMount - * @param {(instance: unknown, wrapper: import('enzyme').ReactWrapper) => void} onRef - Asserts that the ref is a DOM node by default + * The element should have a component wrapped in withStyles as the root */ -function testRef(element, mount, onRef = assertDOMNode) { +function testRef( + element: React.ReactElement, + mount: ConformanceOptions['mount'], + onRef: (instance: unknown, wrapper: import('enzyme').ReactWrapper) => void = assertDOMNode, +) { const ref = React.createRef(); const wrapper = mount({React.cloneElement(element, { ref })}); onRef(ref.current, wrapper); @@ -41,14 +83,11 @@ function testRef(element, mount, onRef = assertDOMNode) { /** * Returns the component with the same constructor as `component` that renders * the outermost host - * @param {import('enzyme').ReactWrapper} wrapper - * @param {object} options - * @param {import('react').ElementType} options.component */ -export function findRootComponent(wrapper, { component }) { +export function findRootComponent(wrapper: ReactWrapper, component: string | React.ElementType) { const outermostHostElement = findOutermostIntrinsic(wrapper).getElement(); - return wrapper.find(component).filterWhere((componentWrapper) => { + return wrapper.find(component as string).filterWhere((componentWrapper) => { return componentWrapper.contains(outermostHostElement); }); } @@ -57,7 +96,7 @@ export function randomStringValue() { return `s${Math.random().toString(36).slice(2)}`; } -function throwMissingPropError(field) { +function throwMissingPropError(field: string) { throw new Error(`missing "${field}" in options > describeConformance(element, () => options) @@ -67,10 +106,8 @@ function throwMissingPropError(field) { /** * MUI components have a `className` prop. The `className` is applied to * the root component. - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -export function testClassName(element, getOptions) { +export function testClassName(element: React.ReactElement, getOptions: () => ConformanceOptions) { it('applies the className to the root component', () => { const { mount } = getOptions(); const className = randomStringValue(); @@ -84,27 +121,26 @@ export function testClassName(element, getOptions) { /** * MUI components have a `component` prop that allows rendering a different * Component from @inheritComponent - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -export function testComponentProp(element, getOptions) { +export function testComponentProp( + element: React.ReactElement, + getOptions: () => ConformanceOptions, +) { describe('prop: component', () => { it('can render another root component with the `component` prop', () => { const { mount, testComponentPropWith: component = 'em' } = getOptions(); const wrapper = mount(React.cloneElement(element, { component })); - expect(findRootComponent(wrapper, { component }).exists()).to.equal(true); + expect(findRootComponent(wrapper, component).exists()).to.equal(true); }); }); } /** * MUI components can spread additional props to a documented component. - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -export function testPropsSpread(element, getOptions) { +export function testPropsSpread(element: React.ReactElement, getOptions: () => ConformanceOptions) { it(`spreads props to the root component`, () => { // type def in ConformanceOptions const { inheritComponent, mount } = getOptions(); @@ -118,7 +154,7 @@ export function testPropsSpread(element, getOptions) { const value = randomStringValue(); const wrapper = mount(React.cloneElement(element, { [testProp]: value })); - const root = findRootComponent(wrapper, { component: inheritComponent }); + const root = findRootComponent(wrapper, inheritComponent); expect(root.props()).to.have.property(testProp, value); }); @@ -129,10 +165,8 @@ export function testPropsSpread(element, getOptions) { * * This is determined by a given constructor i.e. a React.Component or HTMLElement for * components that forward their ref and attach it to a host component. - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -export function describeRef(element, getOptions) { +export function describeRef(element: React.ReactElement, getOptions: () => ConformanceOptions) { describe('ref', () => { it(`attaches the ref`, () => { // type def in ConformanceOptions @@ -141,7 +175,7 @@ export function describeRef(element, getOptions) { testRef(element, mount, (instance, wrapper) => { expect(instance).to.be.instanceof(refInstanceof); - if (inheritComponent !== undefined && instance.nodeType === 1) { + if (inheritComponent !== undefined && (instance as HTMLElement).nodeType === 1) { const rootHost = findOutermostIntrinsic(wrapper); expect(instance).to.equal(rootHost.instance()); } @@ -152,10 +186,8 @@ export function describeRef(element, getOptions) { /** * Tests that the root component has the root class - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -export function testRootClass(element, getOptions) { +export function testRootClass(element: React.ReactElement, getOptions: () => ConformanceOptions) { it('applies the root class to the root component if it has this class', () => { const { classes, render, skip } = getOptions(); if (classes.root == null) { @@ -193,41 +225,40 @@ export function testRootClass(element, getOptions) { /** * Tests that the component can be rendered with react-test-renderer. * This is important for snapshot testing with Jest (even if we don't encourage it). - * @param {React.ReactElement} element */ -export function testReactTestRenderer(element) { +export function testReactTestRenderer(element: React.ReactElement) { it('should render without errors in ReactTestRenderer', () => { ReactTestRenderer.act(() => { ReactTestRenderer.create(element, { createNodeMock: (node) => { - return document.createElement(node.type); + return document.createElement(node.type as keyof HTMLElementTagNameMap); }, }); }); }); } -function forEachSlot(slots, callback) { +function forEachSlot( + slots: ConformanceOptions['slots'], + callback: (slotName: string, slot: SlotTestingOptions) => void, +) { if (!slots) { return; } const slotNames = Object.keys(slots); - for (let i = 0; i < slotNames.length; i += 1) { - const slotName = slotNames[i]; + slotNames.forEach((slotName) => { const slot = slots[slotName]; - callback(slotName, slot); - } + }); } -function testSlotsProp(element, getOptions) { +function testSlotsProp(element: React.ReactElement, getOptions: () => ConformanceOptions) { const { render, slots } = getOptions(); - // eslint-disable-next-line react/prop-types - const CustomComponent = React.forwardRef(({ className }, ref) => ( - - )); + const CustomComponent = React.forwardRef( + ({ className }, ref) => , + ); forEachSlot(slots, (slotName, slotOptions) => { it(`allows overriding the ${slotName} slot with a component using the components.${capitalize( @@ -279,8 +310,10 @@ function testSlotsProp(element, getOptions) { throwMissingPropError('render'); } - // eslint-disable-next-line react/prop-types - const ComponentForComponentsProp = React.forwardRef(({ children }, ref) => { + const ComponentForComponentsProp = React.forwardRef< + HTMLDivElement, + { children: React.ReactNode } + >(({ children }, ref) => { const SlotComponent = slotOptions.testWithComponent ?? 'div'; return ( @@ -289,15 +322,16 @@ function testSlotsProp(element, getOptions) { ); }); - // eslint-disable-next-line react/prop-types - const ComponentForSlotsProp = React.forwardRef(({ children }, ref) => { - const SlotComponent = slotOptions.testWithComponent ?? 'div'; - return ( - - {children} - - ); - }); + const ComponentForSlotsProp = React.forwardRef( + ({ children }, ref) => { + const SlotComponent = slotOptions.testWithComponent ?? 'div'; + return ( + + {children} + + ); + }, + ); const components = { [capitalize(slotName)]: ComponentForComponentsProp, @@ -342,7 +376,7 @@ function testSlotsProp(element, getOptions) { const renderedElement = queryByTestId('customized'); expect(renderedElement).not.to.equal(null); - expect(renderedElement.nodeName.toLowerCase()).to.equal(slotElement); + expect(renderedElement!.nodeName.toLowerCase()).to.equal(slotElement); if (slotOptions.expectedClassName) { expect(renderedElement).to.have.class(slotOptions.expectedClassName); } @@ -372,7 +406,7 @@ function testSlotsProp(element, getOptions) { const renderedElement = queryByTestId('customized'); expect(renderedElement).not.to.equal(null); - expect(renderedElement.nodeName.toLowerCase()).to.equal(slotElement); + expect(renderedElement!.nodeName.toLowerCase()).to.equal(slotElement); if (slotOptions.expectedClassName) { expect(renderedElement).to.have.class(slotOptions.expectedClassName); } @@ -381,7 +415,7 @@ function testSlotsProp(element, getOptions) { }); } -function testSlotPropsProp(element, getOptions) { +function testSlotPropsProp(element: React.ReactElement, getOptions: () => ConformanceOptions) { const { render, slots } = getOptions(); if (!render) { @@ -460,38 +494,18 @@ function testSlotPropsProp(element, getOptions) { }); } -/** - * @typedef {Object} ConformanceOptions - * @property {() => void} [after] - * @property {object} classes - `classes` of the component provided by `@mui/styled-engine` - * @property {import('react').ElementType} [inheritComponent] - The element type that receives spread props or `undefined` if props are not spread. - * @property {string} muiName - * @property {(node: React.ReactElement) => import('./createRenderer').MuiRenderResult} [render] - Should be a return value from createRenderer - * @property {Array} [only] - If specified only run the tests listed - * @property {any} refInstanceof - `ref` will be an instanceof this constructor. - * @property {Array} [skip] - Skip the specified tests - * @property {string} [testComponentsRootPropWith] - The host component that should be rendered instead. - * @property {{ slotName: string, slotClassName: string } | Array<{ slotName: string, slotClassName: string }>} [testDeepOverrides] - * @property {{ prop?: string, value?: any, styleKey: string }} [testStateOverrides] - * @property {object} [testVariantProps] - * @property {(mount: (node: React.ReactNode) => import('enzyme').ReactWrapper) => (node: React.ReactNode) => import('enzyme').ReactWrapper} [wrapMount] - You can use this option to mount the component with enzyme in a WrapperComponent. Make sure the returned node corresponds to the input node and not the wrapper component. - * @property {boolean} [testCustomVariant] - The component supports custom variant - */ - /** * MUI components have a `components` prop that allows rendering a different * Components from @inheritComponent - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -function testComponentsProp(element, getOptions) { +function testComponentsProp(element: React.ReactElement, getOptions: () => ConformanceOptions) { describe('prop components:', () => { it('can render another root component with the `components` prop', () => { const { mount, testComponentsRootPropWith: component = 'em' } = getOptions(); const wrapper = mount(React.cloneElement(element, { components: { Root: component } })); - expect(findRootComponent(wrapper, { component }).exists()).to.equal(true); + expect(findRootComponent(wrapper, component).exists()).to.equal(true); }); }); } @@ -499,10 +513,8 @@ function testComponentsProp(element, getOptions) { /** * MUI theme has a components section that allows specifying default props. * Components from @inheritComponent - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -function testThemeDefaultProps(element, getOptions) { +function testThemeDefaultProps(element: React.ReactElement, getOptions: () => ConformanceOptions) { describe('theme default components:', () => { it("respect theme's defaultProps", () => { const testProp = 'data-id'; @@ -536,10 +548,11 @@ function testThemeDefaultProps(element, getOptions) { /** * MUI theme has a components section that allows specifying style overrides. * Components from @inheritComponent - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -function testThemeStyleOverrides(element, getOptions) { +function testThemeStyleOverrides( + element: React.ReactElement, + getOptions: () => ConformanceOptions, +) { describe('theme style overrides:', () => { it("respect theme's styleOverrides custom state", function test() { if (/jsdom/.test(window.navigator.userAgent)) { @@ -573,6 +586,10 @@ function testThemeStyleOverrides(element, getOptions) { }, }); + if (!testStateOverrides.prop) { + return; + } + const { container } = render( {React.cloneElement(element, { @@ -601,7 +618,9 @@ function testThemeStyleOverrides(element, getOptions) { mixBlendMode: 'darken', }; - function resolveDeepOverrides(callback) { + function resolveDeepOverrides( + callback: (styles: Record, slot: SlotTestOverride) => void, + ) { if (!testDeepOverrides) { return {}; } @@ -733,6 +752,10 @@ function testThemeStyleOverrides(element, getOptions) { }, }); + if (!testStateOverrides.prop) { + return; + } + render( {React.cloneElement(element, { @@ -752,10 +775,8 @@ function testThemeStyleOverrides(element, getOptions) { /** * MUI theme has a components section that allows specifying custom variants. * Components from @inheritComponent - * @param {React.ReactElement} element - * @param {() => ConformanceOptions} getOptions */ -function testThemeVariants(element, getOptions) { +function testThemeVariants(element: React.ReactElement, getOptions: () => ConformanceOptions) { describe('theme variants:', () => { it("respect theme's variants", function test() { if (/jsdom/.test(window.navigator.userAgent)) { @@ -819,7 +840,7 @@ function testThemeVariants(element, getOptions) { components: { [muiName]: { styleOverrides: { - root: ({ ownerState }) => ({ + root: ({ ownerState }: { ownerState: any }) => ({ ...(ownerState.variant === 'unknown' && { mixBlendMode: 'darken', }), @@ -858,10 +879,11 @@ const fullSuite = { /** * Tests various aspects of a component that should be equal across MUI * components. - * @param {React.ReactElement} minimalElement - the component with it's minimal required props - * @param {() => ConformanceOptions} getOptions */ -export default function describeConformance(minimalElement, getOptions) { +export default function describeConformance( + minimalElement: React.ReactElement, + getOptions: () => InputConformanceOptions, +) { describe('MUI component API', () => { const { after: runAfterHook = () => {}, @@ -872,8 +894,9 @@ export default function describeConformance(minimalElement, getOptions) { } = getOptions(); let filteredTests = Object.keys(fullSuite).filter( - (testKey) => only.indexOf(testKey) !== -1 && skip.indexOf(testKey) === -1, - ); + (testKey) => + only.indexOf(testKey) !== -1 && skip.indexOf(testKey as keyof typeof fullSuite) === -1, + ) as (keyof typeof fullSuite)[]; const slotBasedTests = ['slotsProp', 'slotPropsProp']; @@ -887,10 +910,9 @@ export default function describeConformance(minimalElement, getOptions) { after(runAfterHook); - function getTestOptions() { + function getTestOptions(): ConformanceOptions { return { ...getOptions(), - mount, }; } diff --git a/test/utils/describeConformanceUnstyled.tsx b/test/utils/describeConformanceUnstyled.tsx index 2188f4e273a564..9f9c83578bf313 100644 --- a/test/utils/describeConformanceUnstyled.tsx +++ b/test/utils/describeConformanceUnstyled.tsx @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { MuiRenderResult, RenderOptions, screen } from './createRenderer'; import { ConformanceOptions, + SlotTestingOptions, describeRef, randomStringValue, testClassName, @@ -10,13 +11,6 @@ import { testReactTestRenderer, } from './describeConformance'; -export interface SlotTestingOptions { - testWithComponent?: React.ComponentType; - testWithElement?: keyof JSX.IntrinsicElements | null; - expectedClassName: string; - isOptional?: boolean; -} - export interface UnstyledConformanceOptions extends Omit, 'render' | 'skip' | 'classes'> { render: ( @@ -24,7 +18,6 @@ export interface UnstyledConformanceOptions options?: RenderOptions | undefined, ) => MuiRenderResult; skip?: (keyof typeof fullSuite)[]; - slots: Record; testComponentPropWith?: string; } diff --git a/yarn.lock b/yarn.lock index da9fe07789368c..6ce7205ad8ae18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3771,6 +3771,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@^18.0.0": + version "18.0.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-18.0.0.tgz#7b7f69ca98821ea5501b21ba24ea7b6139da2243" + integrity sha512-C7/5FBJ3g3sqUahguGi03O79b8afNeSD6T8/GU50oQrJCU0bVCCGQHaGKUbg2Ce8VQEEqTw8/HiS6lXHHdgkdQ== + dependencies: + "@types/react" "*" + "@types/react-transition-group@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416"