From fc93a555079961b1608debb45c413e28d22d668c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 7 Jan 2025 18:06:53 +0100 Subject: [PATCH] basci impl --- src/__tests__/config.test.ts | 3 +- src/__tests__/fire-event-debug.test.tsx | 69 +++++++++++++++++++++++++ src/config.ts | 6 +++ src/fire-event.ts | 50 ++++++++++++++++-- src/helpers/format-element.ts | 21 +++++++- src/helpers/logger.ts | 8 ++- src/helpers/map-props.ts | 1 + src/user-event/clear.ts | 14 ++++- src/user-event/paste.ts | 14 ++++- src/user-event/type/type.ts | 14 ++++- src/user-event/utils/dispatch-event.ts | 7 +++ 11 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/fire-event-debug.test.tsx 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/__tests__/fire-event-debug.test.tsx b/src/__tests__/fire-event-debug.test.tsx new file mode 100644 index 000000000..b8833e6b8 --- /dev/null +++ b/src/__tests__/fire-event-debug.test.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { Pressable, Text, View } from 'react-native'; + +import { configure, fireEvent, render, screen } from '..'; +import { _console } from '../helpers/logger'; + +beforeEach(() => { + jest.spyOn(_console, 'debug').mockImplementation(() => {}); + jest.spyOn(_console, 'info').mockImplementation(() => {}); + jest.spyOn(_console, 'warn').mockImplementation(() => {}); + jest.spyOn(_console, 'error').mockImplementation(() => {}); +}); + +test('should log warning when firing event on element without handler', () => { + render( + + No handler + , + ); + + fireEvent.press(screen.getByText('No handler')); + + expect(_console.warn).toHaveBeenCalledTimes(1); + expect(jest.mocked(_console.warn).mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ Fire Event: no event handler for "press" event found on No handler or any of its ancestors. + " + `); +}); + +test('should log warning when firing event on single disabled element', () => { + render( + + {}} disabled> + Disabled button + + , + ); + + fireEvent.press(screen.getByText('Disabled button')); + + expect(_console.warn).toHaveBeenCalledTimes(1); + expect(jest.mocked(_console.warn).mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ Fire Event: no enabled event handler for "press" event found. Found disabled event handler(s) on: + - (composite element) + " + `); +}); + +test('should log warning about multiple disabled handlers', () => { + render( + + {}} disabled> + {}} disabled> + Nested disabled + + + , + ); + + fireEvent.press(screen.getByText('Nested disabled')); + + expect(_console.warn).toHaveBeenCalledTimes(1); + expect(jest.mocked(_console.warn).mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ Fire Event: no enabled event handler for "press" event found. Found disabled event handler(s) on: + - (composite element) + - (composite element) + " + `); +}); 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 981e6e649..abfc5068e 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -10,7 +10,9 @@ import type { ReactTestInstance } from 'react-test-renderer'; import act from './act'; import { getEventHandler } from './event-handler'; import { isElementMounted, isHostElement } from './helpers/component-tree'; +import { formatElement } from './helpers/format-element'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; +import { logger } from './helpers/logger'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isEditableTextInput } from './helpers/text-input'; import { nativeState } from './native-state'; @@ -74,23 +76,41 @@ export function isEventEnabled( return touchStart === undefined && touchMove === undefined; } +type FindEventHandlerState = { + nearestTouchResponder?: ReactTestInstance; + disabledElements: ReactTestInstance[]; + targetElement: ReactTestInstance; +}; + function findEventHandler( element: ReactTestInstance, eventName: string, - nearestTouchResponder?: ReactTestInstance, + state: FindEventHandlerState = { + disabledElements: [], + targetElement: element, + }, ): EventHandler | null { - const touchResponder = isTouchResponder(element) ? element : nearestTouchResponder; + const touchResponder = isTouchResponder(element) ? element : state.nearestTouchResponder; const handler = getEventHandler(element, eventName, { loose: true }); - if (handler && isEventEnabled(element, eventName, touchResponder)) { - return handler; + if (handler) { + const isEnabled = isEventEnabled(element, eventName, touchResponder); + if (isEnabled) { + return handler; + } else { + state.disabledElements.push(element); + } } if (element.parent === null) { + logger.warn(formatEnabledEventHandlerNotFound(eventName, state)); return null; } - return findEventHandler(element.parent, eventName, touchResponder); + return findEventHandler(element.parent, eventName, { + ...state, + nearestTouchResponder: touchResponder, + }); } // String union type of keys of T that start with on, stripped of 'on' @@ -211,3 +231,23 @@ function tryGetContentOffset(event: unknown): Point | null { return null; } + +function formatEnabledEventHandlerNotFound(eventName: string, state: FindEventHandlerState) { + if (state.disabledElements.length === 0) { + return `Fire Event: no event handler for "${eventName}" event found on ${formatElement( + state.targetElement, + { + compact: true, + }, + )} or any of its ancestors.`; + } + + return `Fire Event: no enabled event handler for "${eventName}" event found. Found disabled event handler(s) on:\n${state.disabledElements + .map( + (e) => + ` - ${formatElement(e, { compact: true })}${ + typeof e.type === 'string' ? '' : ' (composite element)' + }`, + ) + .join('\n')}`; +} diff --git a/src/helpers/format-element.ts b/src/helpers/format-element.ts index 295636db2..40d1d0eb2 100644 --- a/src/helpers/format-element.ts +++ b/src/helpers/format-element.ts @@ -37,7 +37,7 @@ export function formatElement( // 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}`, + type: formatElementName(element.type), props: mapProps ? mapProps(props) : props, children: childrenToDisplay, }, @@ -52,6 +52,25 @@ export function formatElement( ); } +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 d8222d3bb..2de199c67 100644 --- a/src/helpers/logger.ts +++ b/src/helpers/logger.ts @@ -3,6 +3,8 @@ import pc from 'picocolors'; import redent from 'redent'; import * as nodeUtil from 'util'; +import { getConfig } from '../config'; + export const _console = { debug: nodeConsole.debug, info: nodeConsole.info, @@ -12,8 +14,10 @@ export const _console = { export const logger = { debug(message: unknown, ...args: unknown[]) { - const output = formatMessage('●', message, ...args); - _console.debug(pc.dim(output)); + if (getConfig().debug) { + const output = formatMessage('●', message, ...args); + _console.debug(pc.dim(output)); + } }, info(message: unknown, ...args: unknown[]) { diff --git a/src/helpers/map-props.ts b/src/helpers/map-props.ts index 1c268928e..03dc063d7 100644 --- a/src/helpers/map-props.ts +++ b/src/helpers/map-props.ts @@ -28,6 +28,7 @@ const propsToDisplay = [ 'aria-valuenow', 'aria-valuetext', 'defaultValue', + 'disabled', 'editable', 'importantForAccessibility', 'nativeID', diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index 4a0701873..b46b8141b 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -1,7 +1,9 @@ import type { ReactTestInstance } from 'react-test-renderer'; import { ErrorWithStack } from '../helpers/errors'; +import { formatElement } from '../helpers/format-element'; import { isHostTextInput } from '../helpers/host-component-names'; +import { logger } from '../helpers/logger'; import { isPointerEventEnabled } from '../helpers/pointer-events'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; import { EventBuilder } from './event-builder'; @@ -17,7 +19,17 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance) ); } - if (!isEditableTextInput(element) || !isPointerEventEnabled(element)) { + if (!isEditableTextInput(element)) { + logger.warn( + `User Event (clear): element ${formatElement(element, { compact: true })} is not editable.`, + ); + return; + } + + if (!isPointerEventEnabled(element)) { + logger.warn( + `User Event (clear): element ${formatElement(element, { compact: true })} has pointer event handlers disabled.`, + ); return; } diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index 98191d846..132f7c87c 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -8,6 +8,8 @@ import { nativeState } from '../native-state'; import { EventBuilder } from './event-builder'; import type { UserEventInstance } from './setup'; import { dispatchEvent, getTextContentSize, wait } from './utils'; +import { formatElement } from '../helpers/format-element'; +import { logger } from '../helpers/logger'; export async function paste( this: UserEventInstance, @@ -21,7 +23,17 @@ export async function paste( ); } - if (!isEditableTextInput(element) || !isPointerEventEnabled(element)) { + if (!isEditableTextInput(element)) { + logger.warn( + `User Event (paste): element ${formatElement(element, { compact: true })} is not editable.`, + ); + return; + } + + if (!isPointerEventEnabled(element)) { + logger.warn( + `User Event (paste): element ${formatElement(element, { compact: true })} has pointer event handlers disabled.`, + ); return; } diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 8607ef879..ccf26de01 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -9,6 +9,8 @@ import { EventBuilder } from '../event-builder'; import type { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, getTextContentSize, wait } from '../utils'; import { parseKeys } from './parse-keys'; +import { logger } from '../../helpers/logger'; +import { formatElement } from '../../helpers/format-element'; export interface TypeOptions { skipPress?: boolean; @@ -29,11 +31,19 @@ export async function type( ); } - // Skip events if the element is disabled - if (!isEditableTextInput(element) || !isPointerEventEnabled(element)) { + if (!isEditableTextInput(element)) { + logger.warn( + `User Event (type): element ${formatElement(element, { compact: true })} is not editable.`, + ); return; } + if (!isPointerEventEnabled(element)) { + logger.warn( + `User Event (type): element ${formatElement(element, { compact: true })} has pointer event handlers disabled.`, + ); + return; + } const keys = parseKeys(text); if (!options?.skipPress) { diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts index 161d4cfa7..c425a3e30 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/dispatch-event.ts @@ -3,6 +3,8 @@ import type { ReactTestInstance } from 'react-test-renderer'; import act from '../../act'; import { getEventHandler } from '../../event-handler'; import { isElementMounted } from '../../helpers/component-tree'; +import { formatElement } from '../../helpers/format-element'; +import { logger } from '../../helpers/logger'; /** * Basic dispatch event function used by User Event module. @@ -22,6 +24,11 @@ export async function dispatchEvent( const handler = getEventHandler(element, eventName); if (!handler) { + logger.warn( + `User Event: no event handler for "${eventName}" found on ${formatElement(element, { + compact: true, + })}`, + ); return; }