From 20b918a9c4863e9cf14ada1467421e0583e49314 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sat, 12 Sep 2020 09:29:07 +0300 Subject: [PATCH 1/3] Make searchable name for mount and mountHook files --- lib/index.ts | 251 +---------------------------------------------- lib/mount.ts | 171 ++++++++++++++++++++++++++++++++ lib/mountHook.ts | 80 +++++++++++++++ 3 files changed, 255 insertions(+), 247 deletions(-) create mode 100644 lib/mount.ts create mode 100644 lib/mountHook.ts diff --git a/lib/index.ts b/lib/index.ts index 5d0e3ff3..ac31c839 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,248 +1,5 @@ -import * as React from 'react' -import ReactDOM, { unmountComponentAtNode } from 'react-dom' -import getDisplayName from './getDisplayName' -import { injectStylesBeforeElement } from './utils' +export * from './mount' +export * from './mountHook' -const rootId = 'cypress-root' - -const isComponentSpec = () => Cypress.spec.specType === 'component' - -function checkMountModeEnabled() { - if (!isComponentSpec()) { - throw new Error( - `In order to use mount or unmount functions please place the spec in component folder`, - ) - } -} - -/** - * Inject custom style text or CSS file or 3rd party style resources - */ -const injectStyles = (options: MountOptions) => () => { - const document = cy.state('document') - const el = document.getElementById(rootId) - 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-unit-test - * @see https://glebbahmutov.com/blog/my-vision-for-component-tests/ - * @example - ``` - import Hello from './hello.jsx' - import {mount} from 'cypress-react-unit-test' - it('works', () => { - mount() - // use Cypress commands - cy.contains('Hello').click() - }) - ``` - **/ -export const mount = (jsx: React.ReactElement, options: MountOptions = {}) => { - checkMountModeEnabled() - - // 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} ... />` - let logInstance: Cypress.Log - - return cy - .then(() => { - if (options.log !== false) { - logInstance = Cypress.log({ - name: 'mount', - message: [message], - }) - } - }) - .then(injectStyles(options)) - .then(() => { - const document = cy.state('document') as Document - const reactDomToUse = options.ReactDom || ReactDOM - - const el = document.getElementById(rootId) - - if (!el) { - throw new Error( - [ - '[cypress-react-unit-test] 🔥 Hmm, cannot find root element to mount the component.', - 'Did you forget to include the support file?', - 'Check https://github.com/bahmutov/cypress-react-unit-test#install please', - ].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(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 (logInstance) { - const logConsoleProps = { - props: jsx.props, - description: 'Mounts React component', - home: 'https://github.com/bahmutov/cypress-react-unit-test', - } - const componentElement = el.children[0] - - if (componentElement) { - // @ts-ignore - logConsoleProps.yielded = reactDomToUse.findDOMNode(componentElement) - } - - logInstance.set('consoleProps', () => logConsoleProps) - - if (el.children.length) { - logInstance.set( - '$el', - (el.children.item(0) as unknown) as JQuery, - ) - } - } - - return ( - cy - .wrap(userComponent, { log: false }) - .as(displayName) - // by waiting, we give the component's hook a chance to run - // https://github.com/bahmutov/cypress-react-unit-test/issues/200 - .wait(1, { log: false }) - .then(() => { - if (logInstance) { - logInstance.snapshot('mounted') - logInstance.end() - } - - // by returning undefined we keep the previous subject - // which is the mounted component - return undefined - }) - ) - }) -} - -/** - * 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/bahmutov/cypress-react-unit-test/tree/main/cypress/component/basic/unmount - * @example - ``` - import { mount, unmount } from 'cypress-react-unit-test' - it('works', () => { - mount(...) - // interact with the component using Cypress commands - // whenever you want to unmount - unmount() - }) - ``` - */ -export const unmount = () => { - checkMountModeEnabled() - - return cy.then(() => { - cy.log('unmounting...') - const selector = '#' + rootId - return cy.get(selector, { log: false }).then($el => { - unmountComponentAtNode($el[0]) - }) - }) -} - -// mounting hooks inside a test component mostly copied from -// https://github.com/testing-library/react-hooks-testing-library/blob/master/src/pure.js -function resultContainer() { - let value: T | undefined | null = null - let error: Error | null = null - const resolvers: any[] = [] - - const result = { - get current() { - if (error) { - throw error - } - return value - }, - get error() { - return error - }, - } - - const updateResult = (val: T | undefined, err: Error | null = null) => { - value = val - error = err - resolvers.splice(0, resolvers.length).forEach(resolve => resolve()) - } - - return { - result, - addResolver: (resolver: any) => { - resolvers.push(resolver) - }, - setValue: (val: T) => updateResult(val), - setError: (err: Error) => updateResult(undefined, err), - } -} - -type TestHookProps = { - callback: () => void - onError: (e: Error) => void - children: (...args: any[]) => any -} - -function TestHook({ callback, onError, children }: TestHookProps) { - try { - children(callback()) - } catch (err) { - if (err.then) { - throw err - } else { - onError(err) - } - } - - // TODO decide what the test hook component should show - // maybe nothing, or maybe useful information about the hook? - // maybe its current properties? - // return
TestHook
- return null -} - -/** - * Mounts a React hook function in a test component for testing. - * - * @see https://github.com/bahmutov/cypress-react-unit-test#advanced-examples - */ -export const mountHook = (hookFn: (...args: any[]) => any) => { - const { result, setValue, setError } = resultContainer() - - return mount( - React.createElement(TestHook, { - callback: hookFn, - onError: setError, - children: setValue, - }), - ).then(() => { - cy.wrap(result) - }) -} - -export default mount +/** @deprecated */ +export { default } from './mount' diff --git a/lib/mount.ts b/lib/mount.ts new file mode 100644 index 00000000..43b399eb --- /dev/null +++ b/lib/mount.ts @@ -0,0 +1,171 @@ +import * as React from 'react' +import ReactDOM, { unmountComponentAtNode } from 'react-dom' +import getDisplayName from './getDisplayName' +import { injectStylesBeforeElement } from './utils' + +const rootId = 'cypress-root' + +const isComponentSpec = () => Cypress.spec.specType === 'component' + +function checkMountModeEnabled() { + if (!isComponentSpec()) { + throw new Error( + `In order to use mount or unmount functions please place the spec in component folder`, + ) + } +} + +/** + * Inject custom style text or CSS file or 3rd party style resources + */ +const injectStyles = (options: MountOptions) => () => { + const document = cy.state('document') + const el = document.getElementById(rootId) + 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-unit-test + * @see https://glebbahmutov.com/blog/my-vision-for-component-tests/ + * @example + ``` + import Hello from './hello.jsx' + import {mount} from 'cypress-react-unit-test' + it('works', () => { + mount() + // use Cypress commands + cy.contains('Hello').click() + }) + ``` + **/ +export const mount = (jsx: React.ReactElement, options: MountOptions = {}) => { + checkMountModeEnabled() + + // 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} ... />` + let logInstance: Cypress.Log + + return cy + .then(() => { + if (options.log !== false) { + logInstance = Cypress.log({ + name: 'mount', + message: [message], + }) + } + }) + .then(injectStyles(options)) + .then(() => { + const document = cy.state('document') as Document + const reactDomToUse = options.ReactDom || ReactDOM + + const el = document.getElementById(rootId) + + if (!el) { + throw new Error( + [ + '[cypress-react-unit-test] 🔥 Hmm, cannot find root element to mount the component.', + 'Did you forget to include the support file?', + 'Check https://github.com/bahmutov/cypress-react-unit-test#install please', + ].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(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 (logInstance) { + const logConsoleProps = { + props: jsx.props, + description: 'Mounts React component', + home: 'https://github.com/bahmutov/cypress-react-unit-test', + } + const componentElement = el.children[0] + + if (componentElement) { + // @ts-ignore + logConsoleProps.yielded = reactDomToUse.findDOMNode(componentElement) + } + + logInstance.set('consoleProps', () => logConsoleProps) + + if (el.children.length) { + logInstance.set( + '$el', + (el.children.item(0) as unknown) as JQuery, + ) + } + } + + return ( + cy + .wrap(userComponent, { log: false }) + .as(displayName) + // by waiting, we give the component's hook a chance to run + // https://github.com/bahmutov/cypress-react-unit-test/issues/200 + .wait(1, { log: false }) + .then(() => { + if (logInstance) { + logInstance.snapshot('mounted') + logInstance.end() + } + + // by returning undefined we keep the previous subject + // which is the mounted component + return undefined + }) + ) + }) +} + +/** + * 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/bahmutov/cypress-react-unit-test/tree/main/cypress/component/basic/unmount + * @example + ``` + import { mount, unmount } from 'cypress-react-unit-test' + it('works', () => { + mount(...) + // interact with the component using Cypress commands + // whenever you want to unmount + unmount() + }) + ``` + */ +export const unmount = () => { + checkMountModeEnabled() + + return cy.then(() => { + cy.log('unmounting...') + const selector = '#' + rootId + return cy.get(selector, { log: false }).then($el => { + unmountComponentAtNode($el[0]) + }) + }) +} + +/** @deprecated Should be removed in the next major version */ +export default mount diff --git a/lib/mountHook.ts b/lib/mountHook.ts new file mode 100644 index 00000000..97d23c87 --- /dev/null +++ b/lib/mountHook.ts @@ -0,0 +1,80 @@ +import * as React from 'react' +import { mount } from './mount' + +// mounting hooks inside a test component mostly copied from +// https://github.com/testing-library/react-hooks-testing-library/blob/master/src/pure.js +function resultContainer() { + let value: T | undefined | null = null + let error: Error | null = null + const resolvers: any[] = [] + + const result = { + get current() { + if (error) { + throw error + } + return value + }, + get error() { + return error + }, + } + + const updateResult = (val: T | undefined, err: Error | null = null) => { + value = val + error = err + resolvers.splice(0, resolvers.length).forEach(resolve => resolve()) + } + + return { + result, + addResolver: (resolver: any) => { + resolvers.push(resolver) + }, + setValue: (val: T) => updateResult(val), + setError: (err: Error) => updateResult(undefined, err), + } +} + +type TestHookProps = { + callback: () => void + onError: (e: Error) => void + children: (...args: any[]) => any +} + +function TestHook({ callback, onError, children }: TestHookProps) { + try { + children(callback()) + } catch (err) { + if (err.then) { + throw err + } else { + onError(err) + } + } + + // TODO decide what the test hook component should show + // maybe nothing, or maybe useful information about the hook? + // maybe its current properties? + // return
TestHook
+ return null +} + +/** + * Mounts a React hook function in a test component for testing. + * + * @see https://github.com/bahmutov/cypress-react-unit-test#advanced-examples + */ +export const mountHook = (hookFn: (...args: any[]) => any) => { + const { result, setValue, setError } = resultContainer() + + return mount( + React.createElement(TestHook, { + callback: hookFn, + onError: setError, + children: setValue, + }), + ).then(() => { + cy.wrap(result) + }) +} From 9cd45fb5d83234e6f5ac0095d83d9251786ef7e8 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sat, 12 Sep 2020 09:46:57 +0300 Subject: [PATCH 2/3] feat: createMount function --- README.md | 1 + .../basic/styles/css-file/css-file-spec.js | 26 ++++++++++++++++++- .../component/basic/styles/css-file/index.css | 4 +++ lib/mount.ts | 23 ++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c5556b5..52209ec8 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ See [Recipes](./docs/recipes.md) for more examples. ## API - `mount` is the most important function, allows to mount a given React component as a mini web application and interact with it using Cypress commands +- `createMount` factory function that creates new `mount` function with default options - `unmount` removes previously mounted component, mostly useful to test how the component cleans up after itself - `mountHook` mounts a given React Hook in a test component for full testing, see `hooks` example diff --git a/cypress/component/basic/styles/css-file/css-file-spec.js b/cypress/component/basic/styles/css-file/css-file-spec.js index ef116162..94346d7a 100644 --- a/cypress/component/basic/styles/css-file/css-file-spec.js +++ b/cypress/component/basic/styles/css-file/css-file-spec.js @@ -1,6 +1,6 @@ /// import React from 'react' -import { mount } from 'cypress-react-unit-test' +import { createMount, mount } from 'cypress-react-unit-test' describe('cssFile', () => { it('is loaded', () => { @@ -64,4 +64,28 @@ describe('cssFile', () => { expect(parseFloat(value), 'height is < 30px').to.be.lessThan(30) }) }) + + context('Using createMount to simplify global css experience', () => { + const mount = createMount({ + cssFiles: 'cypress/component/basic/styles/css-file/index.css', + }) + + it('createMount green button', () => { + const Component = () => + mount() + + cy.get('button') + .should('have.class', 'green') + .and('have.css', 'background-color', 'rgb(0, 255, 0)') + }) + + it('createMount blue button', () => { + const Component = () => + mount() + + cy.get('button') + .should('have.class', 'blue') + .and('have.css', 'background-color', 'rgb(0, 0, 255)') + }) + }) }) diff --git a/cypress/component/basic/styles/css-file/index.css b/cypress/component/basic/styles/css-file/index.css index 11417d3c..70073269 100644 --- a/cypress/component/basic/styles/css-file/index.css +++ b/cypress/component/basic/styles/css-file/index.css @@ -1,3 +1,7 @@ button.green { background-color: #00ff00; } + +button.blue { + background-color: #0000ff; +} diff --git a/lib/mount.ts b/lib/mount.ts index 43b399eb..c52af87e 100644 --- a/lib/mount.ts +++ b/lib/mount.ts @@ -167,5 +167,28 @@ export const unmount = () => { }) } +/** + * Mount a React component in a blank document; register it as an alias + * To access: use an alias or original component reference + * @function createMount + * @param {React.ReactElement} element - component to mount + * @param {MountOptions} [defaultOptions] - defaultOptions that will be used to mount + * @example + * ``` + * import Hello from './hello.jsx' + * import { createMount } from 'cypress-react-unit-test' + * it('works', () => { + * const mount = createMount({ cssFile: 'path/to/any/css/file.css', }) + * mount() + * // use Cypress commands + * cy.get('button').should('have.css', 'color', 'rgb(124, 12, 109)') + * }) + ``` + **/ +export const createMount = (defaultOptions: MountOptions) => ( + element: React.ReactElement, + options: MountOptions, +) => mount(element, { ...defaultOptions, ...options }) + /** @deprecated Should be removed in the next major version */ export default mount From 831ec3eb990716a3624c84285434292ddd765f40 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sat, 12 Sep 2020 09:50:05 +0300 Subject: [PATCH 3/3] Update description --- lib/mount.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/mount.ts b/lib/mount.ts index c52af87e..049a422d 100644 --- a/lib/mount.ts +++ b/lib/mount.ts @@ -168,17 +168,16 @@ export const unmount = () => { } /** - * Mount a React component in a blank document; register it as an alias - * To access: use an alias or original component reference + * Creates new instance of `mount` function with default options * @function createMount * @param {React.ReactElement} element - component to mount - * @param {MountOptions} [defaultOptions] - defaultOptions that will be used to mount + * @param {MountOptions} [defaultOptions] - defaultOptions for returned `mount` function * @example * ``` * import Hello from './hello.jsx' * import { createMount } from 'cypress-react-unit-test' * it('works', () => { - * const mount = createMount({ cssFile: 'path/to/any/css/file.css', }) + * const mount = createMount({ strict: true, cssFile: 'path/to/any/css/file.css' }) * mount() * // use Cypress commands * cy.get('button').should('have.css', 'color', 'rgb(124, 12, 109)')