From 2047bb27dba496745fc132e2546345ad6a223b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= Date: Tue, 15 Oct 2024 13:42:50 +0800 Subject: [PATCH] feat: inula-testing-library --- packages/inula-testing-library/.gitignore | 3 + packages/inula-testing-library/LICENSE | 194 ++++++++++ packages/inula-testing-library/jest.config.js | 19 + packages/inula-testing-library/package.json | 31 ++ .../src/__tests__/inula/render.test.js | 62 ++++ packages/inula-testing-library/src/index.js | 6 + .../src/rtl/act-compat.js | 91 +++++ .../inula-testing-library/src/rtl/config.js | 34 ++ .../src/rtl/fire-event.js | 69 ++++ .../inula-testing-library/src/rtl/index.js | 41 +++ .../inula-testing-library/src/rtl/pure.js | 331 ++++++++++++++++++ .../inula-testing-library/types/index.tsx | 52 +++ 12 files changed, 933 insertions(+) create mode 100644 packages/inula-testing-library/.gitignore create mode 100644 packages/inula-testing-library/LICENSE create mode 100644 packages/inula-testing-library/jest.config.js create mode 100644 packages/inula-testing-library/package.json create mode 100644 packages/inula-testing-library/src/__tests__/inula/render.test.js create mode 100644 packages/inula-testing-library/src/index.js create mode 100644 packages/inula-testing-library/src/rtl/act-compat.js create mode 100644 packages/inula-testing-library/src/rtl/config.js create mode 100644 packages/inula-testing-library/src/rtl/fire-event.js create mode 100644 packages/inula-testing-library/src/rtl/index.js create mode 100644 packages/inula-testing-library/src/rtl/pure.js create mode 100644 packages/inula-testing-library/types/index.tsx diff --git a/packages/inula-testing-library/.gitignore b/packages/inula-testing-library/.gitignore new file mode 100644 index 00000000..26a0361f --- /dev/null +++ b/packages/inula-testing-library/.gitignore @@ -0,0 +1,3 @@ +/node_modules +.vscode +package-lock.json \ No newline at end of file diff --git a/packages/inula-testing-library/LICENSE b/packages/inula-testing-library/LICENSE new file mode 100644 index 00000000..f6c26977 --- /dev/null +++ b/packages/inula-testing-library/LICENSE @@ -0,0 +1,194 @@ +木兰宽松许可证,第2版 + +木兰宽松许可证,第2版 + +2020年1月 http://license.coscl.org.cn/MulanPSL2 + +您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束: + +0. 定义 + +“软件” 是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。 + +“贡献” 是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。 + +“贡献者” 是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。 + +“法人实体” 是指提交贡献的机构及其“关联实体”。 + +“关联实体” 是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是 +指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 + +1. 授予版权许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可 +以复制、使用、修改、分发其“贡献”,不论修改与否。 + +2. 授予专利许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定 +撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡 +献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软 +件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“ +关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或 +其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权 +行动之日终止。 + +3. 无商标许可 + +“本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定 +的声明义务而必须使用除外。 + +4. 分发限制 + +您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“ +本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。 + +5. 免责声明与责任限制 + +“软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对 +任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于 +何种法律理论,即使其曾被建议有此种损失的可能性。 + +6. 语言 + +“本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文 +版为准。 + +条款结束 + +如何将木兰宽松许可证,第2版,应用到您的软件 + +如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步: + +1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字; + +2, 请您在软件包的一级目录下创建以“LICENSE”为名的文件,将整个许可证文本放入该文件中; + +3, 请将如下声明文本放入每个源文件的头部注释中。 + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan +PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. + +Mulan Permissive Software License,Version 2 + +Mulan Permissive Software License,Version 2 (Mulan PSL v2) + +January 2020 http://license.coscl.org.cn/MulanPSL2 + +Your reproduction, use, modification and distribution of the Software shall +be subject to Mulan PSL v2 (this License) with the following terms and +conditions: + +0. Definition + +Software means the program and related documents which are licensed under +this License and comprise all Contribution(s). + +Contribution means the copyrightable work licensed by a particular +Contributor under this License. + +Contributor means the Individual or Legal Entity who licenses its +copyrightable work under this License. + +Legal Entity means the entity making a Contribution and all its +Affiliates. + +Affiliates means entities that control, are controlled by, or are under +common control with the acting entity under this License, ‘control’ means +direct or indirect ownership of at least fifty percent (50%) of the voting +power, capital or other securities of controlled or commonly controlled +entity. + +1. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to you a perpetual, worldwide, royalty-free, non-exclusive, +irrevocable copyright license to reproduce, use, modify, or distribute its +Contribution, with modification or not. + +2. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to you a perpetual, worldwide, royalty-free, non-exclusive, +irrevocable (except for revocation under this Section) patent license to +make, have made, use, offer for sale, sell, import or otherwise transfer its +Contribution, where such patent license is only limited to the patent claims +owned or controlled by such Contributor now or in future which will be +necessarily infringed by its Contribution alone, or by combination of the +Contribution with the Software to which the Contribution was contributed. +The patent license shall not apply to any modification of the Contribution, +and any other combination which includes the Contribution. If you or your +Affiliates directly or indirectly institute patent litigation (including a +cross claim or counterclaim in a litigation) or other patent enforcement +activities against any individual or entity by alleging that the Software or +any Contribution in it infringes patents, then any patent license granted to +you under this License for the Software shall terminate as of the date such +litigation or activity is filed or taken. + +3. No Trademark License + +No trademark license is granted to use the trade names, trademarks, service +marks, or product names of Contributor, except as required to fulfill notice +requirements in section 4. + +4. Distribution Restriction + +You may distribute the Software in any medium with or without modification, +whether in source or executable forms, provided that you provide recipients +with a copy of this License and retain copyright, patent, trademark and +disclaimer statements in the Software. + +5. Disclaimer of Warranty and Limitation of Liability + +THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY +KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR +COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT +LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING +FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO +MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF +THE POSSIBILITY OF SUCH DAMAGES. + +6. Language + +THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION +AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF +DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION +SHALL PREVAIL. + +END OF THE TERMS AND CONDITIONS + +How to Apply the Mulan Permissive Software License,Version 2 +(Mulan PSL v2) to Your Software + +To apply the Mulan PSL v2 to your work, for easy identification by +recipients, you are suggested to complete following three steps: + +i. Fill in the blanks in following statement, including insert your software +name, the year of the first publication of your software, and your name +identified as the copyright owner; + +ii. Create a file named "LICENSE" which contains the whole context of this +License in the first directory of your software package; + +iii. Attach the statement to the appropriate annotated syntax at the +beginning of each source file. + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan +PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. diff --git a/packages/inula-testing-library/jest.config.js b/packages/inula-testing-library/jest.config.js new file mode 100644 index 00000000..860358cd --- /dev/null +++ b/packages/inula-testing-library/jest.config.js @@ -0,0 +1,19 @@ +const {jest: jestConfig} = require('kcd-scripts/config') + +module.exports = Object.assign(jestConfig, { + coverageThreshold: { + ...jestConfig.coverageThreshold, + // Full coverage across the build matrix (React 18, 19) but not in a single job + // Ful coverage is checked via codecov + './src/act-compat': { + branches: 90, + }, + './src/pure': { + // minimum coverage of jobs using React 18 and 19 + branches: 95, + functions: 88, + lines: 92, + statements: 92, + }, + }, +}) diff --git a/packages/inula-testing-library/package.json b/packages/inula-testing-library/package.json new file mode 100644 index 00000000..bb37ece8 --- /dev/null +++ b/packages/inula-testing-library/package.json @@ -0,0 +1,31 @@ +{ + "name": "inula-testing-library-demo", + "version": "1.0.0", + "description": "", + "main": "index.ts", + "scripts": { + "test": "kcd-scripts test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "devDependencies": { + "@testing-library/dom": "^10.3.1", + "@testing-library/jest-dom": "^6.4.6", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "chalk": "^4.1.2", + "dotenv-cli": "^4.0.0", + "jest-diff": "^29.7.0", + "npm-run-all": "^4.1.5", + "kcd-scripts": "^16.0.0", + "rimraf": "^5.0.8", + "typescript": "^4.1.2", + "openinula": "0.1.3", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} \ No newline at end of file diff --git a/packages/inula-testing-library/src/__tests__/inula/render.test.js b/packages/inula-testing-library/src/__tests__/inula/render.test.js new file mode 100644 index 00000000..090f39a4 --- /dev/null +++ b/packages/inula-testing-library/src/__tests__/inula/render.test.js @@ -0,0 +1,62 @@ +import { useState, useEffect, createRef } from "openinula"; +import React from "react"; +import { render, configure } from "../../rtl"; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, info) { + console.error("ErrorBoundary caught an error", error); + } + + render() { + if (this.state.hasError) { + return
Error
; + } + return this.props.children; + } +} + +const Foo = () => { + const [text, setText] = useState("text1"); + useEffect(() => { + setTimeout(() => { + setText("text2"); + }, 300); + }, []); + return
{text}
; +}; + +describe("render API", () => { + let originalConfig + beforeEach(() => { + // Grab the existing configuration so we can restore + // it at the end of the test + configure(existingConfig => { + originalConfig = existingConfig + // Don't change the existing config + return {} + }) + }) + + afterEach(() => { + configure(originalConfig) + }) + + test("renders without crashing", () => { + render(); + }); + + test('renders div into document', () => { + const ref = createRef() + const { container } = render(
) + expect(container.firstChild).toBe(ref.current) + }) +}); \ No newline at end of file diff --git a/packages/inula-testing-library/src/index.js b/packages/inula-testing-library/src/index.js new file mode 100644 index 00000000..fd60245c --- /dev/null +++ b/packages/inula-testing-library/src/index.js @@ -0,0 +1,6 @@ +import * as rtlAPI from './rtl' + +const { render, renderHook, cleanup, act, fireEvent, getConfig, configure } = rtlAPI + +export * from '@testing-library/dom' +export { render, renderHook, cleanup, act, fireEvent, getConfig, configure } diff --git a/packages/inula-testing-library/src/rtl/act-compat.js b/packages/inula-testing-library/src/rtl/act-compat.js new file mode 100644 index 00000000..6eaec0fb --- /dev/null +++ b/packages/inula-testing-library/src/rtl/act-compat.js @@ -0,0 +1,91 @@ +import * as React from 'react' +import * as DeprecatedReactTestUtils from 'react-dom/test-utils' + +const reactAct = + typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act + +function getGlobalThis() { + /* istanbul ignore else */ + if (typeof globalThis !== 'undefined') { + return globalThis + } + /* istanbul ignore next */ + if (typeof self !== 'undefined') { + return self + } + /* istanbul ignore next */ + if (typeof window !== 'undefined') { + return window + } + /* istanbul ignore next */ + if (typeof global !== 'undefined') { + return global + } + /* istanbul ignore next */ + throw new Error('unable to locate global object') +} + +function setIsReactActEnvironment(isReactActEnvironment) { + getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment +} + +function getIsReactActEnvironment() { + return getGlobalThis().IS_REACT_ACT_ENVIRONMENT +} + +function withGlobalActEnvironment(actImplementation) { + return callback => { + const previousActEnvironment = getIsReactActEnvironment() + setIsReactActEnvironment(true) + try { + // The return value of `act` is always a thenable. + let callbackNeedsToBeAwaited = false + const actResult = actImplementation(() => { + const result = callback() + if ( + result !== null && + typeof result === 'object' && + typeof result.then === 'function' + ) { + callbackNeedsToBeAwaited = true + } + return result + }) + if (callbackNeedsToBeAwaited) { + const thenable = actResult + return { + then: (resolve, reject) => { + thenable.then( + returnValue => { + setIsReactActEnvironment(previousActEnvironment) + resolve(returnValue) + }, + error => { + setIsReactActEnvironment(previousActEnvironment) + reject(error) + }, + ) + }, + } + } else { + setIsReactActEnvironment(previousActEnvironment) + return actResult + } + } catch (error) { + // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT + // or if we have to await the callback first. + setIsReactActEnvironment(previousActEnvironment) + throw error + } + } +} + +const act = withGlobalActEnvironment(reactAct) + +export default act +export { + setIsReactActEnvironment as setReactActEnvironment, + getIsReactActEnvironment, +} + +/* eslint no-console:0 */ diff --git a/packages/inula-testing-library/src/rtl/config.js b/packages/inula-testing-library/src/rtl/config.js new file mode 100644 index 00000000..dc8a5035 --- /dev/null +++ b/packages/inula-testing-library/src/rtl/config.js @@ -0,0 +1,34 @@ +import { + getConfig as getConfigDTL, + configure as configureDTL, +} from '@testing-library/dom' + +let configForRTL = { + reactStrictMode: false, +} + +function getConfig() { + return { + ...getConfigDTL(), + ...configForRTL, + } +} + +function configure(newConfig) { + if (typeof newConfig === 'function') { + // Pass the existing config out to the provided function + // and accept a delta in return + newConfig = newConfig(getConfig()) + } + + const {reactStrictMode, ...configForDTL} = newConfig + + configureDTL(configForDTL) + + configForRTL = { + ...configForRTL, + reactStrictMode, + } +} + +export {getConfig, configure} diff --git a/packages/inula-testing-library/src/rtl/fire-event.js b/packages/inula-testing-library/src/rtl/fire-event.js new file mode 100644 index 00000000..cb790c7f --- /dev/null +++ b/packages/inula-testing-library/src/rtl/fire-event.js @@ -0,0 +1,69 @@ +import {fireEvent as dtlFireEvent} from '@testing-library/dom' + +// react-testing-library's version of fireEvent will call +// dom-testing-library's version of fireEvent. The reason +// we make this distinction however is because we have +// a few extra events that work a bit differently +const fireEvent = (...args) => dtlFireEvent(...args) + +Object.keys(dtlFireEvent).forEach(key => { + fireEvent[key] = (...args) => dtlFireEvent[key](...args) +}) + +// React event system tracks native mouseOver/mouseOut events for +// running onMouseEnter/onMouseLeave handlers +// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/EnterLeaveEventPlugin.js#L24-L31 +const mouseEnter = fireEvent.mouseEnter +const mouseLeave = fireEvent.mouseLeave +fireEvent.mouseEnter = (...args) => { + mouseEnter(...args) + return fireEvent.mouseOver(...args) +} +fireEvent.mouseLeave = (...args) => { + mouseLeave(...args) + return fireEvent.mouseOut(...args) +} + +const pointerEnter = fireEvent.pointerEnter +const pointerLeave = fireEvent.pointerLeave +fireEvent.pointerEnter = (...args) => { + pointerEnter(...args) + return fireEvent.pointerOver(...args) +} +fireEvent.pointerLeave = (...args) => { + pointerLeave(...args) + return fireEvent.pointerOut(...args) +} + +const select = fireEvent.select +fireEvent.select = (node, init) => { + select(node, init) + // React tracks this event only on focused inputs + node.focus() + + // React creates this event when one of the following native events happens + // - contextMenu + // - mouseUp + // - dragEnd + // - keyUp + // - keyDown + // so we can use any here + // @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/SelectEventPlugin.js#L203-L224 + fireEvent.keyUp(node, init) +} + +// React event system tracks native focusout/focusin events for +// running blur/focus handlers +// @link https://github.com/facebook/react/pull/19186 +const blur = fireEvent.blur +const focus = fireEvent.focus +fireEvent.blur = (...args) => { + fireEvent.focusOut(...args) + return blur(...args) +} +fireEvent.focus = (...args) => { + fireEvent.focusIn(...args) + return focus(...args) +} + +export {fireEvent} diff --git a/packages/inula-testing-library/src/rtl/index.js b/packages/inula-testing-library/src/rtl/index.js new file mode 100644 index 00000000..b618c3d5 --- /dev/null +++ b/packages/inula-testing-library/src/rtl/index.js @@ -0,0 +1,41 @@ +import { getIsReactActEnvironment, setReactActEnvironment } from './act-compat' +import { cleanup } from './pure' + +// if we're running in a test runner that supports afterEach +// or teardown then we'll automatically run cleanup afterEach test +// this ensures that tests run in isolation from each other +// if you don't like this then either import the `pure` module +// or set the RTL_SKIP_AUTO_CLEANUP env variable to 'true'. +if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { + // ignore teardown() in code coverage because Jest does not support it + /* istanbul ignore else */ + if (typeof afterEach === 'function') { + afterEach(() => { + cleanup() + }) + } else if (typeof teardown === 'function') { + // Block is guarded by `typeof` check. + // eslint does not support `typeof` guards. + // eslint-disable-next-line no-undef + teardown(() => { + cleanup() + }) + } + + // No test setup with other test runners available + /* istanbul ignore else */ + if (typeof beforeAll === 'function' && typeof afterAll === 'function') { + // This matches the behavior of React < 18. + let previousIsReactActEnvironment = getIsReactActEnvironment() + beforeAll(() => { + previousIsReactActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(true) + }) + + afterAll(() => { + setReactActEnvironment(previousIsReactActEnvironment) + }) + } +} + +export * from './pure' diff --git a/packages/inula-testing-library/src/rtl/pure.js b/packages/inula-testing-library/src/rtl/pure.js new file mode 100644 index 00000000..ea7d85d5 --- /dev/null +++ b/packages/inula-testing-library/src/rtl/pure.js @@ -0,0 +1,331 @@ +import * as React from 'react' +import ReactDOM from 'react-dom' +import * as ReactDOMClient from 'react-dom/client' +import { + getQueriesForElement, + prettyDOM, + configure as configureDTL, +} from '@testing-library/dom' +import act, { + getIsReactActEnvironment, + setReactActEnvironment, +} from './act-compat' +import { fireEvent } from './fire-event' +import { getConfig, configure } from './config' + +/** + * 检查jest的模拟定时器是否启用 + * + * 本函数旨在确定当前环境下是否使用了jest的模拟定时器这在测试环境中尤为重要, + * 因为模拟定时器可以帮助我们更好地控制异步行为,而不受实际时间流逝的影响 + * + * @returns {boolean} 如果jest的模拟定时器已启用,则返回true;否则返回false + */ +function jestFakeTimersAreEnabled() { + /* istanbul ignore else */ // 忽略此条件块以进行代码覆盖率计算,因为jest的定义状态会导致条件不总是可用 + if (typeof jest !== 'undefined' && jest !== null) { // 检查jest是否已定义且不为null,这通常意味着jest环境已被加载 + return ( + // legacy timers + setTimeout._isMockFunction === true || // 检查setTimeout是否被标记为mock函数,这是旧版jest模拟定时器的一个标志 + // eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support. + Object.prototype.hasOwnProperty.call(setTimeout, 'clock') // 检查setTimeout对象是否有'cock'属性,这是新版jest模拟定时器的标志 + ) + } /* istanbul ignore next */ + + return false // 如果jest未定义或为null,则返回false,表示没有启用模拟定时器 +} + +// 配置DTL(Delay The Load)策略,用于优化测试环境下的异步操作和事件处理 +configureDTL({ + // 包装不稳定的定时器操作,确保在非测试环境下正常运行 + unstable_advanceTimersWrapper: cb => { + return act(cb) + }, + // 定义异步操作的包装器,用于处理异步测试逻辑 + asyncWrapper: async cb => { + // 保存当前的React Act环境状态 + const previousActEnvironment = getIsReactActEnvironment() + // 设置React Act环境为false,以模拟非测试环境 + setReactActEnvironment(false) + try { + // 执行回调函数,并返回结果 + const result = await cb() + // 清空微任务队列,以确保在resolve `waitFor`调用之前,恢复之前的Act环境 + await new Promise(resolve => { + // 使用setTimeout来确保微任务队列被清空 + setTimeout(() => { + resolve() + }, 0) + + // 如果使用的是Jest假定时器,则前进定时器以清空队列 + if (jestFakeTimersAreEnabled()) { + jest.advanceTimersByTime(0) + } + }) + + return result + } finally { + // 恢复到原始的React Act环境状态 + setReactActEnvironment(previousActEnvironment) + } + }, + // 为事件处理提供包装,确保事件在Act环境下执行 + eventWrapper: cb => { + let result + // 在Act环境下执行回调,以确保事件立即执行 + act(() => { + result = cb() + }) + return result + }, +}) + +// Ideally we'd just use a WeakMap where containers are keys and roots are values. +// We use two variables so that we can bail out in constant time when we render with a new container (most common use case) +/** + * @type {Set} + */ +const mountedContainers = new Set() +/** + * @type Array<{container: import('react-dom').Container, root: ReturnType}> + */ +const mountedRootEntries = [] + +function strictModeIfNeeded(innerElement) { + return getConfig().reactStrictMode + ? React.createElement(React.StrictMode, null, innerElement) + : innerElement +} + +function wrapUiIfNeeded(innerElement, wrapperComponent) { + return wrapperComponent + ? React.createElement(wrapperComponent, null, innerElement) + : innerElement +} + +function createConcurrentRoot( + container, + { hydrate, ui, wrapper: WrapperComponent }, +) { + let root + if (hydrate) { + act(() => { + root = ReactDOMClient.hydrateRoot( + container, + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + ) + }) + } else { + root = ReactDOMClient.createRoot(container) + } + + return { + hydrate() { + /* istanbul ignore if */ + if (!hydrate) { + throw new Error( + 'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/react`.', + ) + } + // Nothing to do since hydration happens when creating the root object. + }, + render(element) { + root.render(element) + }, + unmount() { + root.unmount() + }, + } +} + +function createLegacyRoot(container) { + return { + hydrate(element) { + ReactDOM.hydrate(element, container) + }, + render(element) { + ReactDOM.render(element, container) + }, + unmount() { + ReactDOM.unmountComponentAtNode(container) + }, + } +} + +function renderRoot( + ui, + { baseElement, container, hydrate, queries, root, wrapper: WrapperComponent }, +) { + act(() => { + if (hydrate) { + root.hydrate( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } else { + root.render( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } + }) + + return { + container, + baseElement, + debug: (el = baseElement, maxLength, options) => + Array.isArray(el) + ? // eslint-disable-next-line no-console + el.forEach(e => console.log(prettyDOM(e, maxLength, options))) + : // eslint-disable-next-line no-console, + console.log(prettyDOM(el, maxLength, options)), + unmount: () => { + act(() => { + root.unmount() + }) + }, + rerender: rerenderUi => { + renderRoot(rerenderUi, { + container, + baseElement, + root, + wrapper: WrapperComponent, + }) + // Intentionally do not return anything to avoid unnecessarily complicating the API. + // folks can use all the same utilities we return in the first place that are bound to the container + }, + asFragment: () => { + /* istanbul ignore else (old jsdom limitation) */ + if (typeof document.createRange === 'function') { + return document + .createRange() + .createContextualFragment(container.innerHTML) + } else { + const template = document.createElement('template') + template.innerHTML = container.innerHTML + return template.content + } + }, + ...getQueriesForElement(baseElement, queries), + } +} + +function render( + ui, + { + container, + baseElement = container, + legacyRoot = false, + queries, + hydrate = false, + wrapper, + } = {}, +) { + if (legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, render) + throw error + } + + if (!baseElement) { + // default to document.body instead of documentElement to avoid output of potentially-large + // head elements (such as JSS style blocks) in debug output + baseElement = document.body + } + if (!container) { + container = baseElement.appendChild(document.createElement('div')) + } + + let root + // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first. + if (!mountedContainers.has(container)) { + const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot + root = createRootImpl(container, { hydrate, ui, wrapper }) + + mountedRootEntries.push({ container, root }) + // we'll add it to the mounted containers regardless of whether it's actually + // added to document.body so the cleanup method works regardless of whether + // they're passing us a custom container or not. + mountedContainers.add(container) + } else { + mountedRootEntries.forEach(rootEntry => { + // Else is unreachable since `mountedContainers` has the `container`. + // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` + /* istanbul ignore else */ + if (rootEntry.container === container) { + root = rootEntry.root + } + }) + } + + return renderRoot(ui, { + container, + baseElement, + queries, + hydrate, + wrapper, + root, + }) +} + +function cleanup() { + mountedRootEntries.forEach(({ root, container }) => { + act(() => { + root.unmount() + }) + if (container.parentNode === document.body) { + document.body.removeChild(container) + } + }) + mountedRootEntries.length = 0 + mountedContainers.clear() +} + +function renderHook(renderCallback, options = {}) { + const { initialProps, ...renderOptions } = options + + if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, renderHook) + throw error + } + + const result = React.createRef() + + function TestComponent({ renderCallbackProps }) { + const pendingResult = renderCallback(renderCallbackProps) + + React.useEffect(() => { + result.current = pendingResult + }) + + return null + } + + const { rerender: baseRerender, unmount } = render( + , + renderOptions, + ) + + function rerender(rerenderCallbackProps) { + return baseRerender( + , + ) + } + + return { result, rerender, unmount } +} + +// just re-export everything from dom-testing-library +export * from '@testing-library/dom' +export { render, renderHook, cleanup, act, fireEvent, getConfig, configure } + +/* eslint func-name-matching:0 */ diff --git a/packages/inula-testing-library/types/index.tsx b/packages/inula-testing-library/types/index.tsx new file mode 100644 index 00000000..4fa4984e --- /dev/null +++ b/packages/inula-testing-library/types/index.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { queries as defaultQueries } from "@testing-library/dom"; +import * as ReactDOMClient from "react-dom/client"; + +export interface RenderOptions { + container?: HTMLElement; + baseElement?: HTMLElement; + legacyRoot?: boolean; + queries?: typeof defaultQueries; + hydrate?: boolean; + wrapper?: React.ComponentType; +} + +export interface RenderRootOptions { + baseElement?: HTMLElement; + container?: HTMLElement; + hydrate?: boolean; + queries?: typeof defaultQueries; + root: ReactDOMClient.Root; + wrapper?: React.ComponentType; +} + +export interface RenderResult { + container: HTMLElement; + baseElement: HTMLElement; + debug: ( + el?: HTMLElement | HTMLElement[], + maxLength?: number, + options?: object + ) => void; + unmount: () => void; + rerender: (rerenderUi: React.ReactElement) => void; + asFragment: () => DocumentFragment; + [key: string]: any; +} + +export function strictModeIfNeeded(ui: React.ReactElement): React.ReactElement { + if (process.env.NODE_ENV === "production") { + return ui; + } + return {ui}; +} + +export function wrapUiIfNeeded( + ui: React.ReactElement, + WrapperComponent?: React.ComponentType +): React.ReactElement { + if (!WrapperComponent) { + return ui; + } + return {ui}; +}