diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index dc454bea9..8150ef188 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -14,9 +14,10 @@ test('configure() overrides existing config values', () => { configure({ defaultDebugOptions: { message: 'debug message' } }); expect(getConfig()).toEqual({ asyncUtilTimeout: 5000, + concurrentRoot: true, + debug: false, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - concurrentRoot: true, }); }); diff --git a/src/config.ts b/src/config.ts index e861d0eb1..793e2ae9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,11 @@ export type Config = { * Otherwise `render` will default to concurrent rendering. */ concurrentRoot: boolean; + + /** + * Verbose logging for the library. + */ + debug: boolean; }; export type ConfigAliasOptions = { @@ -30,6 +35,7 @@ const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, concurrentRoot: true, + debug: false, }; let config = { ...defaultConfig }; diff --git a/src/fire-event.ts b/src/fire-event.ts index c659fcfe4..1a6fca35d 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -8,7 +8,9 @@ import type { import type { ReactTestInstance } from 'react-test-renderer'; import act from './act'; import { isElementMounted, isHostElement } from './helpers/component-tree'; +import { formatElement } from './helpers/format-element'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; +import { debugLogger } from './helpers/logger'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isEditableTextInput } from './helpers/text-input'; import { nativeState } from './native-state'; @@ -47,29 +49,43 @@ const textInputEventsIgnoringEditableProp = new Set([ 'onScroll', ]); -export function isEventEnabled( +type EventHandlerState = { + enabled: boolean; + reason?: string; +}; + +function getEventHandlerState( element: ReactTestInstance, eventName: string, nearestTouchResponder?: ReactTestInstance, -) { +): EventHandlerState { if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { - return ( - isEditableTextInput(nearestTouchResponder) || - textInputEventsIgnoringEditableProp.has(eventName) - ); + if (isEditableTextInput(nearestTouchResponder)) { + return { enabled: true }; + } + + if (textInputEventsIgnoringEditableProp.has(eventName)) { + return { enabled: true }; + } + + return { enabled: false, reason: '"editable" prop' }; } if (eventsAffectedByPointerEventsProp.has(eventName) && !isPointerEventEnabled(element)) { - return false; + return { enabled: false, reason: '"pointerEvents" prop' }; } const touchStart = nearestTouchResponder?.props.onStartShouldSetResponder?.(); const touchMove = nearestTouchResponder?.props.onMoveShouldSetResponder?.(); if (touchStart || touchMove) { - return true; + return { enabled: true }; + } + + if (touchStart === undefined && touchMove === undefined) { + return { enabled: true }; } - return touchStart === undefined && touchMove === undefined; + return { enabled: false, reason: 'not being a touch responder' }; } function findEventHandler( @@ -80,7 +96,19 @@ function findEventHandler( const touchResponder = isTouchResponder(element) ? element : nearestTouchResponder; const handler = getEventHandler(element, eventName); - if (handler && isEventEnabled(element, eventName, touchResponder)) return handler; + if (handler) { + const handlerState = getEventHandlerState(element, eventName, touchResponder); + + if (handlerState.enabled) { + return handler; + } else { + debugLogger.warn( + `FireEvent: "${eventName}" event handler is disabled on ${formatElement(element, { + compact: true, + })} due to ${handlerState.reason}.`, + ); + } + } // eslint-disable-next-line @typescript-eslint/prefer-optional-chain if (element.parent === null || element.parent.parent === null) { @@ -129,6 +157,12 @@ function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: un const handler = findEventHandler(element, eventName); if (!handler) { + debugLogger.warn( + `FireEvent: no enabled event handler for "${eventName}" found on ${formatElement(element, { + compact: true, + })} or its ancestors.`, + ); + return; } diff --git a/src/helpers/format-element.ts b/src/helpers/format-element.ts index 2170f83b2..b5e2a3587 100644 --- a/src/helpers/format-element.ts +++ b/src/helpers/format-element.ts @@ -31,26 +31,48 @@ export function formatElement( const { children, ...props } = element.props; const childrenToDisplay = typeof children === 'string' ? [children] : undefined; - return prettyFormat( - { - // This prop is needed persuade the prettyFormat that the element is - // a ReactTestRendererJSON instance, so it is formatted as JSX. - $$typeof: Symbol.for('react.test.json'), - type: `${element.type}`, - props: mapProps ? mapProps(props) : props, - children: childrenToDisplay, - }, - // See: https://www.npmjs.com/package/pretty-format#usage-with-options - { - plugins: [plugins.ReactTestComponent, plugins.ReactElement], - printFunctionName: false, - printBasicPrototype: false, - highlight: highlight, - min: compact, - }, + return ( + (typeof element.type === 'string' ? '' : 'composite ') + + prettyFormat( + { + // This prop is needed persuade the prettyFormat that the element is + // a ReactTestRendererJSON instance, so it is formatted as JSX. + $$typeof: Symbol.for('react.test.json'), + type: formatElementName(element.type), + props: mapProps ? mapProps(props) : props, + children: childrenToDisplay, + }, + // See: https://www.npmjs.com/package/pretty-format#usage-with-options + { + plugins: [plugins.ReactTestComponent, plugins.ReactElement], + printFunctionName: false, + printBasicPrototype: false, + highlight: highlight, + min: compact, + }, + ) ); } +function formatElementName(type: ReactTestInstance['type']) { + if (typeof type === 'function') { + return type.displayName ?? type.name; + } + + if (typeof type === 'object') { + if ('type' in type) { + // @ts-expect-error: despite typing this can happen for React.memo. + return formatElementName(type.type); + } + if ('render' in type) { + // @ts-expect-error: despite typing this can happen for React.forwardRefs. + return formatElementName(type.render); + } + } + + return `${type}`; +} + export function formatElementList(elements: ReactTestInstance[], options?: FormatElementOptions) { if (elements.length === 0) { return '(no elements)'; diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts index eccb4dc37..1710b6343 100644 --- a/src/helpers/logger.ts +++ b/src/helpers/logger.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import * as nodeConsole from 'console'; import redent from 'redent'; import * as nodeUtil from 'util'; +import { getConfig } from '../config'; export const logger = { debug(message: unknown, ...args: unknown[]) { @@ -25,6 +26,32 @@ export const logger = { }, }; +export const debugLogger = { + debug(message: unknown, ...args: unknown[]) { + if (getConfig().debug) { + logger.debug(message, ...args); + } + }, + + info(message: unknown, ...args: unknown[]) { + if (getConfig().debug) { + logger.info(message, ...args); + } + }, + + warn(message: unknown, ...args: unknown[]) { + if (getConfig().debug) { + logger.warn(message, ...args); + } + }, + + error(message: unknown, ...args: unknown[]) { + if (getConfig().debug) { + logger.error(message, ...args); + } + }, +}; + function formatMessage(symbol: string, message: unknown, ...args: unknown[]) { const formatted = nodeUtil.format(message, ...args); const indented = redent(formatted, 4); diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts index 3ae2551de..566652ad5 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/dispatch-event.ts @@ -1,6 +1,8 @@ import type { ReactTestInstance } from 'react-test-renderer'; import act from '../../act'; import { isElementMounted } from '../../helpers/component-tree'; +import { formatElement } from '../../helpers/format-element'; +import { debugLogger } from '../../helpers/logger'; /** * Basic dispatch event function used by User Event module. @@ -16,6 +18,11 @@ export function dispatchEvent(element: ReactTestInstance, eventName: string, ... const handler = getEventHandler(element, eventName); if (!handler) { + debugLogger.debug( + `User Event: no event handler for "${eventName}" found on ${formatElement(element, { + compact: true, + })}`, + ); return; }