Skip to content

feat: event debugging (WIP) #1726

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});

6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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 };
54 changes: 44 additions & 10 deletions src/fire-event.ts
Original file line number Diff line number Diff line change
@@ -8,7 +8,9 @@
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 @@
'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' };

Check warning on line 71 in src/fire-event.ts

Codecov / codecov/patch

src/fire-event.ts#L66-L71

Added lines #L66 - L71 were not covered by tests
}

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 @@
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 @@

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;
}

56 changes: 39 additions & 17 deletions src/helpers/format-element.ts
Original file line number Diff line number Diff line change
@@ -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)';
27 changes: 27 additions & 0 deletions src/helpers/logger.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@
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 debugLogger = {
debug(message: unknown, ...args: unknown[]) {
if (getConfig().debug) {
logger.debug(message, ...args);
}

Check warning on line 33 in src/helpers/logger.ts

Codecov / codecov/patch

src/helpers/logger.ts#L32-L33

Added lines #L32 - L33 were not covered by tests
},

info(message: unknown, ...args: unknown[]) {
if (getConfig().debug) {
logger.info(message, ...args);
}

Check warning on line 39 in src/helpers/logger.ts

Codecov / codecov/patch

src/helpers/logger.ts#L37-L39

Added lines #L37 - L39 were not covered by tests
},

warn(message: unknown, ...args: unknown[]) {
if (getConfig().debug) {
logger.warn(message, ...args);
}

Check warning on line 45 in src/helpers/logger.ts

Codecov / codecov/patch

src/helpers/logger.ts#L44-L45

Added lines #L44 - L45 were not covered by tests
},

error(message: unknown, ...args: unknown[]) {
if (getConfig().debug) {
logger.error(message, ...args);
}
},

Check warning on line 52 in src/helpers/logger.ts

Codecov / codecov/patch

src/helpers/logger.ts#L49-L52

Added lines #L49 - L52 were not covered by tests
};

function formatMessage(symbol: string, message: unknown, ...args: unknown[]) {
const formatted = nodeUtil.format(message, ...args);
const indented = redent(formatted, 4);
7 changes: 7 additions & 0 deletions src/user-event/utils/dispatch-event.ts
Original file line number Diff line number Diff line change
@@ -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;
}