-
Notifications
You must be signed in to change notification settings - Fork 3.2k
/
Copy pathcreateMount.ts
207 lines (181 loc) · 6.88 KB
/
createMount.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
import * as React from 'react'
import ReactDOM from 'react-dom'
import getDisplayName from './getDisplayName'
import {
injectStylesBeforeElement,
getContainerEl,
ROOT_SELECTOR,
setupHooks,
} from '@cypress/mount-utils'
import type { InternalMountOptions, InternalUnmountOptions, MountOptions, MountReturn, UnmountArgs } from './types'
/**
* Inject custom style text or CSS file or 3rd party style resources
*/
const injectStyles = (options: MountOptions) => {
return (): HTMLElement => {
const el = getContainerEl()
return injectStylesBeforeElement(options, document, el)
}
}
export let lastMountedReactDom: (typeof ReactDOM) | undefined
/**
* Create an `mount` function. Performs all the non-React-version specific
* behavior related to mounting. The React-version-specific code
* is injected. This helps us to maintain a consistent public API
* and handle breaking changes in React's rendering API.
*
* This is designed to be consumed by `npm/react{16,17,18}`, and other React adapters,
* or people writing adapters for third-party, custom adapters.
*/
export const makeMountFn = (
type: 'mount' | 'rerender',
jsx: React.ReactNode,
options: MountOptions = {},
rerenderKey?: string,
internalMountOptions?: InternalMountOptions,
): globalThis.Cypress.Chainable<MountReturn> => {
if (!internalMountOptions) {
throw Error('internalMountOptions must be provided with `render` and `reactDom` parameters')
}
// 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 jsxComponentName = `<${componentName} ... />`
const message = options.alias
? `${jsxComponentName} as "${options.alias}"`
: jsxComponentName
return cy
.then(injectStyles(options))
.then(() => {
const reactDomToUse = internalMountOptions.reactDom
lastMountedReactDom = reactDomToUse
const el = getContainerEl()
if (!el) {
throw new Error(
[
`[@cypress/react] 🔥 Hmm, cannot find root element to mount the component. Searched for ${ROOT_SELECTOR}`,
].join(' '),
)
}
const key = rerenderKey ??
// @ts-ignore provide unique key to the the wrapped component to make sure we are rerendering between tests
(Cypress?.mocha?.getRunner()?.test?.title as string || '') + 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
const userComponent = (reactComponent.props as {
key: string
children: React.ReactNode
}).children
internalMountOptions.render(reactComponent, el, reactDomToUse)
if (options.log !== false) {
Cypress.log({
name: type,
type: 'parent',
message: [message],
// @ts-ignore
$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: type === 'mount' ? 'Mounts React component' : 'Rerenders mounted React component',
home: 'https://github.com/cypress-io/cypress',
}
},
}).snapshot('mounted').end()
}
return (
// Separate alias and returned value. Alias returns the component only, and the thenable returns the additional functions
cy.wrap<React.ReactNode>(userComponent, { log: false })
.as(displayName)
.then(() => {
return cy.wrap<MountReturn>({
component: userComponent,
rerender: (newComponent) => makeMountFn('rerender', newComponent, options, key, internalMountOptions),
unmount: internalMountOptions.unmount,
}, { log: false })
})
// 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 })
)
// Bluebird types are terrible. I don't think the return type can be carried without this cast
}) as unknown as globalThis.Cypress.Chainable<MountReturn>
}
/**
* Create an `unmount` function. Performs all the non-React-version specific
* behavior related to unmounting.
*
* This is designed to be consumed by `npm/react{16,17,18}`, and other React adapters,
* or people writing adapters for third-party, custom adapters.
*/
export const makeUnmountFn = (options: UnmountArgs, internalUnmountOptions: InternalUnmountOptions) => {
return cy.then(() => {
return cy.get(ROOT_SELECTOR, { log: false }).then(($el) => {
if (lastMountedReactDom) {
internalUnmountOptions.unmount($el[0])
const wasUnmounted = internalUnmountOptions.unmount($el[0])
if (wasUnmounted && options.log) {
Cypress.log({
name: 'unmount',
type: 'parent',
message: [options.boundComponentMessage ?? 'Unmounted component'],
consoleProps: () => {
return {
description: 'Unmounts React component',
parent: $el[0],
home: 'https://github.com/cypress-io/cypress',
}
},
})
}
}
})
})
}
// Cleanup before each run
// NOTE: we cannot use unmount here because
// we are not in the context of a test
const preMountCleanup = () => {
const el = getContainerEl()
if (el && lastMountedReactDom) {
lastMountedReactDom.unmountComponentAtNode(el)
}
}
const _mount = (jsx: React.ReactNode, options: MountOptions = {}) => makeMountFn('mount', jsx, options)
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 */
// TODO: Remove
export default _mount
export interface JSX extends Function {
displayName: string
}
// Side effects from "import { mount } from '@cypress/<my-framework>'" are annoying, we should avoid doing this
// by creating an explicit function/import that the user can register in their 'component.js' support file,
// such as:
// import 'cypress/<my-framework>/support'
// or
// import { registerCT } from 'cypress/<my-framework>'
// registerCT()
// Note: This would be a breaking change
// 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(preMountCleanup)