From e544e1771a8a6bc3ab34deafaeea7061ac57b12b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 28 Jun 2024 00:04:13 -0400 Subject: [PATCH] Add debug util for UI tests --- package-lock.json | 41 +++++++++++++--- package.json | 2 + tests/utils/debug.ts | 114 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 tests/utils/debug.ts diff --git a/package-lock.json b/package-lock.json index ff202a2fda59..4aa476d5ffe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -186,6 +186,7 @@ "@types/react-beautiful-dnd": "^13.1.4", "@types/react-collapse": "^5.0.1", "@types/react-dom": "^18.2.4", + "@types/react-is": "^18.3.0", "@types/react-test-renderer": "^18.0.0", "@types/semver": "^7.5.4", "@types/setimmediate": "^1.0.2", @@ -235,6 +236,7 @@ "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", + "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", "react-test-renderer": "18.2.0", "reassure": "^0.10.1", @@ -9963,6 +9965,11 @@ "react": "*" } }, + "node_modules/@react-navigation/core/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/@react-navigation/devtools": { "version": "6.0.10", "dev": true, @@ -18001,6 +18008,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-is": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.3.0.tgz", + "integrity": "sha512-KZJpHUkAdzyKj/kUHJDc6N7KyidftICufJfOFpiG6haL/BDQNQt5i4n1XDUL/nDZAtGLHDSWRYpLzKTAKSvX6w==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-native": { "version": "0.73.0", "deprecated": "This is a stub types definition. react-native provides its own type definitions, so you do not need this installed.", @@ -20448,6 +20464,12 @@ "node": ">= 6" } }, + "node_modules/babel-plugin-react-compiler/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, "node_modules/babel-plugin-react-compiler/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -27965,6 +27987,11 @@ "react-is": "^16.7.0" } }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "dev": true, @@ -36156,10 +36183,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "18.2.0", - "license": "MIT" - }, "node_modules/pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", @@ -36238,6 +36261,11 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/propagate": { "version": "2.0.1", "license": "MIT", @@ -36895,8 +36923,9 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "license": "MIT" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, "node_modules/react-map-gl": { "version": "7.1.3", diff --git a/package.json b/package.json index 39e6cefa775c..1ec9c8dac3b6 100644 --- a/package.json +++ b/package.json @@ -239,6 +239,7 @@ "@types/react-beautiful-dnd": "^13.1.4", "@types/react-collapse": "^5.0.1", "@types/react-dom": "^18.2.4", + "@types/react-is": "^18.3.0", "@types/react-test-renderer": "^18.0.0", "@types/semver": "^7.5.4", "@types/setimmediate": "^1.0.2", @@ -288,6 +289,7 @@ "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", + "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", "react-test-renderer": "18.2.0", "reassure": "^0.10.1", diff --git a/tests/utils/debug.ts b/tests/utils/debug.ts new file mode 100644 index 000000000000..b33acdf1d5d4 --- /dev/null +++ b/tests/utils/debug.ts @@ -0,0 +1,114 @@ +/** + * The debug utility that ships with react native testing library does not work properly and + * has limited functionality. This is a better version of it that allows logging a subtree of + * the app. + */ + +/* eslint-disable no-console, testing-library/no-node-access, testing-library/no-debugging-utils, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ +import type {NewPlugin} from 'pretty-format'; +import prettyFormat, {plugins} from 'pretty-format'; +import ReactIs from 'react-is'; +import type {ReactTestInstance, ReactTestRendererJSON} from 'react-test-renderer'; + +// These are giant objects and cause the serializer to crash because the +// output becomes too large. +const NativeComponentPlugin: NewPlugin = { + // eslint-disable-next-line no-underscore-dangle + test: (val) => !!val?._reactInternalInstance, + serialize: () => 'NativeComponentInstance {}', +}; + +type Options = { + includeProps?: boolean; + maxDepth?: number; +}; + +const format = (input: ReactTestRendererJSON | ReactTestRendererJSON[], options: Options) => + prettyFormat(input, { + plugins: [plugins.ReactTestComponent, plugins.ReactElement, NativeComponentPlugin], + highlight: true, + printBasicPrototype: false, + maxDepth: options.maxDepth, + }); + +function getType(element: any) { + const type = element.type; + if (typeof type === 'string') { + return type; + } + if (typeof type === 'function') { + return type.displayName || type.name || 'Unknown'; + } + + if (ReactIs.isFragment(element)) { + return 'React.Fragment'; + } + if (ReactIs.isSuspense(element)) { + return 'React.Suspense'; + } + if (typeof type === 'object' && type !== null) { + if (ReactIs.isContextProvider(element)) { + return 'Context.Provider'; + } + + if (ReactIs.isContextConsumer(element)) { + return 'Context.Consumer'; + } + + if (ReactIs.isForwardRef(element)) { + if (type.displayName) { + return type.displayName; + } + + const functionName = type.render.displayName || type.render.name || ''; + + return functionName === '' ? 'ForwardRef' : `ForwardRef(${functionName})`; + } + + if (ReactIs.isMemo(element)) { + const functionName = type.displayName || type.type.displayName || type.type.name || ''; + + return functionName === '' ? 'Memo' : `Memo(${functionName})`; + } + } + return 'UNDEFINED'; +} + +function getProps(props: Record, options: Options) { + if (!options.includeProps) { + return {}; + } + const {children, ...propsWithoutChildren} = props; + return propsWithoutChildren; +} + +function toJSON(node: ReactTestInstance, options: Options): ReactTestRendererJSON { + const json = { + $$typeof: Symbol.for('react.test.json'), + type: getType({type: node.type, $$typeof: Symbol.for('react.element')}), + props: getProps(node.props, options), + children: node.children?.map((c) => (typeof c === 'string' ? c : toJSON(c, options))) ?? null, + }; + + return json; +} + +function formatNode(node: ReactTestInstance, options: Options) { + return format(toJSON(node, options), options); +} + +/** + * Log a subtree of the app for debugging purposes. + * + * @example debug(screen.getByTestId('report-actions-view-wrapper')); + */ +export default function debug(node: ReactTestInstance | ReactTestInstance[] | null, {includeProps = true, maxDepth = Infinity}: Options = {}): void { + const options = {includeProps, maxDepth}; + if (node == null) { + console.log('null'); + } else if (Array.isArray(node)) { + console.log(node.map((n) => formatNode(n, options)).join('\n')); + } else { + console.log(formatNode(node, options)); + } +}