Files
cypress/npm/react/src/mount.ts
T
Lachlan Miller fe0b63c299 fix: improve handling of userland injected styles in component testing (#16024)
* feat(npm/react): do not clear head between tests

* add a shared mount utils library

* add readme

* update dependencies

* add mount utils to circle

* change module

Co-authored-by: Barthélémy Ledoux <bart@cypress.io>
2021-04-22 01:48:48 +10:00

269 lines
7.8 KiB
TypeScript

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import getDisplayName from './getDisplayName'
import {
injectStylesBeforeElement,
StyleOptions,
ROOT_ID,
setupHooks,
} from '@cypress/mount-utils'
/**
* Inject custom style text or CSS file or 3rd party style resources
*/
const injectStyles = (options: MountOptions) => {
return () => {
const el = document.getElementById(ROOT_ID)
return injectStylesBeforeElement(options, document, el)
}
}
/**
* Mount a React component in a blank document; register it as an alias
* To access: use an alias or original component reference
* @function mount
* @param {React.ReactElement} jsx - component to mount
* @param {MountOptions} [options] - options, like alias, styles
* @see https://github.com/bahmutov/@cypress/react
* @see https://glebbahmutov.com/blog/my-vision-for-component-tests/
* @example
```
import Hello from './hello.jsx'
import {mount} from '@cypress/react'
it('works', () => {
mount(<Hello onClick={cy.stub()} />)
// use Cypress commands
cy.contains('Hello').click()
})
```
**/
export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
// Get the display name property via the component constructor
// @ts-ignore FIXME
const componentName = getDisplayName(jsx.type, options.alias)
const displayName = options.alias || componentName
const message = options.alias
? `<${componentName} ... /> as "${options.alias}"`
: `<${componentName} ... />`
return cy
.then(injectStyles(options))
.then(() => {
const reactDomToUse = options.ReactDom || ReactDOM
const el = document.getElementById(ROOT_ID)
if (!el) {
throw new Error(
[
'[@cypress/react] 🔥 Hmm, cannot find root element to mount the component.',
].join(' '),
)
}
const key =
// @ts-ignore provide unique key to the the wrapped component to make sure we are rerendering between tests
(Cypress?.mocha?.getRunner()?.test?.title || '') + Math.random()
const props = {
key,
}
const reactComponent = React.createElement(
options.strict ? React.StrictMode : React.Fragment,
props,
jsx,
)
// since we always surround the component with a fragment
// let's get back the original component
// @ts-ignore
const userComponent = reactComponent.props.children
reactDomToUse.render(reactComponent, el)
if (options.log !== false) {
Cypress.log({
name: 'mount',
message: [message],
$el: (el.children.item(0) as unknown) as JQuery<HTMLElement>,
consoleProps: () => {
return {
// @ts-ignore protect the use of jsx functional components use ReactNode
props: jsx.props,
description: 'Mounts React component',
home: 'https://github.com/cypress-io/cypress',
}
},
}).snapshot('mounted').end()
}
return (
cy
.wrap(userComponent, { log: false })
.as(displayName)
// by waiting, we delaying test execution for the next tick of event loop
// and letting hooks and component lifecycle methods to execute mount
// https://github.com/bahmutov/cypress-react-unit-test/issues/200
.wait(0, { log: false })
)
})
}
/**
* Removes the mounted component. Notice this command automatically
* queues up the `unmount` into Cypress chain, thus you don't need `.then`
* to call it.
* @see https://github.com/cypress-io/cypress/tree/develop/npm/react/cypress/component/basic/unmount
* @example
```
import { mount, unmount } from '@cypress/react'
it('works', () => {
mount(...)
// interact with the component using Cypress commands
// whenever you want to unmount
unmount()
})
```
*/
export const unmount = (options = { log: true }) => {
return cy.then(() => {
const selector = `#${ROOT_ID}`
return cy.get(selector, { log: false }).then(($el) => {
const wasUnmounted = ReactDOM.unmountComponentAtNode($el[0])
if (wasUnmounted && options.log) {
cy.log('Unmounted component at', $el)
}
})
})
}
// Cleanup before each run
// NOTE: we cannot use unmount here because
// we are not in the context of a test
Cypress.on('test:before:run', () => {
const el = document.getElementById(ROOT_ID)
if (el) {
ReactDOM.unmountComponentAtNode(el)
}
})
/**
* Creates new instance of `mount` function with default options
* @function createMount
* @param {MountOptions} [defaultOptions] - defaultOptions for returned `mount` function
* @returns new instance of `mount` with assigned options
* @example
* ```
* import Hello from './hello.jsx'
* import { createMount } from '@cypress/react'
*
* const mount = createMount({ strict: true, cssFile: 'path/to/any/css/file.css' })
*
* it('works', () => {
* mount(<Hello />)
* // use Cypress commands
* cy.get('button').should('have.css', 'color', 'rgb(124, 12, 109)')
* })
```
**/
export const createMount = (defaultOptions: MountOptions) => {
return (
element: React.ReactElement,
options?: MountOptions,
) => {
return mount(element, { ...defaultOptions, ...options })
}
}
/** @deprecated Should be removed in the next major version */
export default mount
// I hope to get types and docs from functions imported from ./index one day
// but for now have to document methods in both places
// like this: import {mount} from './index'
export interface ReactModule {
name: string
type: string
location: string
source: string
}
export interface MountReactComponentOptions {
alias: string
ReactDom: typeof ReactDOM
/**
* Log the mounting command into Cypress Command Log,
* true by default.
*/
log: boolean
/**
* Render component in React [strict mode](https://reactjs.org/docs/strict-mode.html)
* It activates additional checks and warnings for child components.
*/
strict: boolean
}
export type MountOptions = Partial<StyleOptions & MountReactComponentOptions>
/**
* The `type` property from the transpiled JSX object.
* @example
* const { type } = React.createElement('div', null, 'Hello')
* const { type } = <div>Hello</div>
*/
export interface JSX extends Function {
displayName: string
}
export declare namespace Cypress {
interface Cypress {
stylesCache: any
React: string
ReactDOM: string
Styles: string
modules: ReactModule[]
}
// NOTE: By default, avoiding React.Component/Element typings
// for many cases, we don't want to import @types/react
interface Chainable<Subject> {
// adding quick "cy.state" method to avoid TS errors
state: (key: string) => any
// injectReactDOM: () => Chainable<void>
// copyCompon { ReactDom }entStyles: (component: Symbol) => Chainable<void>
/**
* Mount a React component in a blank document; register it as an alias
* To access: use an alias or original component reference
* @function cy.mount
* @param {Object} jsx - component to mount
* @param {string} [Component] - alias to use later
* @example
```
import Hello from './hello.jsx'
// mount and access by alias
cy.mount(<Hello />, 'Hello')
// using default alias
cy.get('@Component')
// using specified alias
cy.get('@Hello').its('state').should(...)
// using original component
cy.get(Hello)
```
**/
// mount: (component: Symbol, alias?: string) => Chainable<void>
get<S = any>(
alias: string | symbol | Function,
options?: Partial<any>,
): Chainable<any>
}
}
// it is required to unmount component in beforeEach hook in order to provide a clean state inside test
// because `mount` can be called after some preparation that can side effect unmount
// @see npm/react/cypress/component/advanced/set-timeout-example/loading-indicator-spec.js
setupHooks(unmount)