From 289828b1b90e79d4ad3bfc227a2e68bd88f13344 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 2 Aug 2022 14:44:13 +0200 Subject: [PATCH] fix(event): be robust against incomplete event implementations (#1009) --- src/event/createEvent.ts | 276 +++++++++++++++++++++++++++--------- src/event/dom-events.d.ts | 19 ++- src/event/eventMap.ts | 14 +- src/event/index.ts | 4 +- src/event/types.ts | 22 +++ tests/_helpers/listeners.ts | 2 + tests/keyboard/modifiers.ts | 3 +- 7 files changed, 261 insertions(+), 79 deletions(-) diff --git a/src/event/createEvent.ts b/src/event/createEvent.ts index cbe89e58..3b46731c 100644 --- a/src/event/createEvent.ts +++ b/src/event/createEvent.ts @@ -1,98 +1,234 @@ -import {createEvent as createEventBase} from '@testing-library/dom' -import {eventMap, eventMapKeys, isMouseEvent} from './eventMap' -import {EventType, PointerCoords} from './types' +import {getWindow} from '../utils' +import {eventMap, eventMapKeys} from './eventMap' +import type {EventType, EventTypeInit, FixedDocumentEventMap} from './types' -export type EventTypeInit = SpecificEventInit< - FixedDocumentEventMap[K] -> - -interface FixedDocumentEventMap extends DocumentEventMap { - input: InputEvent -} - -type SpecificEventInit = E extends InputEvent - ? InputEventInit - : E extends ClipboardEvent - ? ClipboardEventInit - : E extends KeyboardEvent - ? KeyboardEventInit - : E extends PointerEvent - ? PointerEventInit - : E extends MouseEvent - ? MouseEventInit - : E extends UIEvent - ? UIEventInit - : EventInit +const eventInitializer = { + ClipboardEvent: [initClipboardEvent], + InputEvent: [initUIEvent, initInputEvent], + MouseEvent: [initUIEvent, initUIEventModififiers, initMouseEvent], + PointerEvent: [ + initUIEvent, + initUIEventModififiers, + initMouseEvent, + initPointerEvent, + ], + KeyboardEvent: [initUIEvent, initUIEventModififiers, initKeyboardEvent], +} as Record void>> export function createEvent( type: K, target: Element, init?: EventTypeInit, ) { - const event = createEventBase( - type, - target, - init, - eventMap[eventMapKeys[type] as keyof typeof eventMap], - ) as DocumentEventMap[K] + const window = getWindow(target) + const {EventType, defaultInit} = + eventMap[eventMapKeys[type] as keyof typeof eventMap] + const event = new (getEventConstructors(window)[EventType])(type, defaultInit) + eventInitializer[EventType]?.forEach(f => f(event, init ?? {})) - // Can not use instanceof, as MouseEvent might be polyfilled. - if (isMouseEvent(type) && init) { - // see https://github.com/testing-library/react-testing-library/issues/268 - assignPositionInit(event as MouseEvent, init) - assignPointerInit(event as PointerEvent, init) - } + return event as FixedDocumentEventMap[K] +} - return event +/* istanbul ignore next */ +function getEventConstructors(window: Window & typeof globalThis) { + /* eslint-disable @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-extraneous-class */ + const Event = window.Event ?? class Event {} + const AnimationEvent = + window.AnimationEvent ?? class AnimationEvent extends Event {} + const ClipboardEvent = + window.ClipboardEvent ?? class ClipboardEvent extends Event {} + const PopStateEvent = + window.PopStateEvent ?? class PopStateEvent extends Event {} + const ProgressEvent = + window.ProgressEvent ?? class ProgressEvent extends Event {} + const TransitionEvent = + window.TransitionEvent ?? class TransitionEvent extends Event {} + const UIEvent = window.UIEvent ?? class UIEvent extends Event {} + const CompositionEvent = + window.CompositionEvent ?? class CompositionEvent extends UIEvent {} + const FocusEvent = window.FocusEvent ?? class FocusEvent extends UIEvent {} + const InputEvent = window.InputEvent ?? class InputEvent extends UIEvent {} + const KeyboardEvent = + window.KeyboardEvent ?? class KeyboardEvent extends UIEvent {} + const MouseEvent = window.MouseEvent ?? class MouseEvent extends UIEvent {} + const DragEvent = window.DragEvent ?? class DragEvent extends MouseEvent {} + const PointerEvent = + window.PointerEvent ?? class PointerEvent extends MouseEvent {} + const TouchEvent = window.TouchEvent ?? class TouchEvent extends UIEvent {} + /* eslint-enable @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-extraneous-class */ + + return { + Event, + AnimationEvent, + ClipboardEvent, + PopStateEvent, + ProgressEvent, + TransitionEvent, + UIEvent, + CompositionEvent, + FocusEvent, + InputEvent, + KeyboardEvent, + MouseEvent, + DragEvent, + PointerEvent, + TouchEvent, + } } -function assignProps( - obj: MouseEvent | PointerEvent, - props: MouseEventInit & PointerEventInit & PointerCoords, -) { +function assignProps(obj: T, props: {[k in keyof T]?: T[k]}) { for (const [key, value] of Object.entries(props)) { - Object.defineProperty(obj, key, {get: () => value}) + Object.defineProperty(obj, key, {get: () => value ?? null}) } } -function assignPositionInit( - obj: MouseEvent | PointerEvent, +function sanitizeNumber(n: number | undefined) { + return Number(n ?? 0) +} + +function initClipboardEvent( + event: ClipboardEvent, + {clipboardData}: ClipboardEventInit, +) { + assignProps(event, { + clipboardData, + }) +} + +function initInputEvent( + event: InputEvent, + {data, inputType, isComposing}: InputEventInit, +) { + assignProps(event, { + data, + isComposing: Boolean(isComposing), + inputType: String(inputType), + }) +} + +function initUIEvent(event: UIEvent, {view, detail}: UIEventInit) { + assignProps(event, { + view, + detail: sanitizeNumber(detail ?? 0), + }) +} + +function initUIEventModififiers( + event: KeyboardEvent | MouseEvent, + { + altKey, + ctrlKey, + metaKey, + shiftKey, + modifierAltGraph, + modifierCapsLock, + modifierFn, + modifierFnLock, + modifierNumLock, + modifierScrollLock, + modifierSymbol, + modifierSymbolLock, + }: EventModifierInit, +) { + assignProps(event, { + altKey: Boolean(altKey), + ctrlKey: Boolean(ctrlKey), + metaKey: Boolean(metaKey), + shiftKey: Boolean(shiftKey), + getModifierState(k: string) { + return Boolean( + { + Alt: altKey, + AltGraph: modifierAltGraph, + CapsLock: modifierCapsLock, + Control: ctrlKey, + Fn: modifierFn, + FnLock: modifierFnLock, + Meta: metaKey, + NumLock: modifierNumLock, + ScrollLock: modifierScrollLock, + Shift: shiftKey, + Symbol: modifierSymbol, + SymbolLock: modifierSymbolLock, + }[k], + ) + }, + }) +} + +function initKeyboardEvent( + event: KeyboardEvent, + { + key, + code, + location, + repeat, + isComposing, + charCode, // `charCode` is necessary for React17 `keypress` + }: KeyboardEventInit, +) { + assignProps(event, { + key: String(key), + code: String(code), + location: sanitizeNumber(location), + repeat: Boolean(repeat), + isComposing: Boolean(isComposing), + charCode, + }) +} + +function initMouseEvent( + event: MouseEvent, { x, y, - clientX, - clientY, - offsetX, - offsetY, - pageX, - pageY, screenX, screenY, - }: PointerCoords & MouseEventInit, + clientX = x, + clientY = y, + button, + buttons, + relatedTarget, + }: MouseEventInit & {x?: number; y?: number}, ) { - assignProps(obj, { - /* istanbul ignore start */ - x: x ?? clientX ?? 0, - y: y ?? clientY ?? 0, - clientX: x ?? clientX ?? 0, - clientY: y ?? clientY ?? 0, - offsetX: offsetX ?? 0, - offsetY: offsetY ?? 0, - pageX: pageX ?? 0, - pageY: pageY ?? 0, - screenX: screenX ?? 0, - screenY: screenY ?? 0, - /* istanbul ignore end */ + assignProps(event, { + screenX: sanitizeNumber(screenX), + screenY: sanitizeNumber(screenY), + clientX: sanitizeNumber(clientX), + x: sanitizeNumber(clientX), + clientY: sanitizeNumber(clientY), + y: sanitizeNumber(clientY), + button: sanitizeNumber(button), + buttons: sanitizeNumber(buttons), + relatedTarget, }) } -function assignPointerInit( - obj: MouseEvent | PointerEvent, - {isPrimary, pointerId, pointerType}: PointerEventInit, -) { - assignProps(obj, { - isPrimary, +function initPointerEvent( + event: PointerEvent, + { pointerId, + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, pointerType, + isPrimary, + }: PointerEventInit, +) { + assignProps(event, { + pointerId: sanitizeNumber(pointerId), + width: sanitizeNumber(width), + height: sanitizeNumber(height), + pressure: sanitizeNumber(pressure), + tangentialPressure: sanitizeNumber(tangentialPressure), + tiltX: sanitizeNumber(tiltX), + tiltY: sanitizeNumber(tiltY), + twist: sanitizeNumber(twist), + pointerType: String(pointerType), + isPrimary: Boolean(isPrimary), }) } diff --git a/src/event/dom-events.d.ts b/src/event/dom-events.d.ts index 18d4fbd1..cb335782 100644 --- a/src/event/dom-events.d.ts +++ b/src/event/dom-events.d.ts @@ -2,8 +2,25 @@ declare module '@testing-library/dom/dist/event-map.js' { import {EventType} from '@testing-library/dom' export const eventMap: { [k in EventType]: { - EventType: string + EventType: EventInterface defaultInit: EventInit } } } + +type EventInterface = + | 'AnimationEvent' + | 'ClipboardEvent' + | 'CompositionEvent' + | 'DragEvent' + | 'Event' + | 'FocusEvent' + | 'InputEvent' + | 'KeyboardEvent' + | 'MouseEvent' + | 'PointerEvent' + | 'PopStateEvent' + | 'ProgressEvent' + | 'TouchEvent' + | 'TransitionEvent' + | 'UIEvent' diff --git a/src/event/eventMap.ts b/src/event/eventMap.ts index 75a80cc1..ee628b44 100644 --- a/src/event/eventMap.ts +++ b/src/event/eventMap.ts @@ -4,17 +4,23 @@ import {EventType} from './types' export const eventMap = { ...baseEventMap, + click: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, auxclick: { - // like other events this should be PointerEvent, but this is missing in Jsdom - // see https://github.com/jsdom/jsdom/issues/2527 - EventType: 'MouseEvent', + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + contextmenu: { + EventType: 'PointerEvent', defaultInit: {bubbles: true, cancelable: true, composed: true}, }, beforeInput: { EventType: 'InputEvent', defaultInit: {bubbles: true, cancelable: true, composed: true}, }, -} +} as const export const eventMapKeys: { [k in keyof DocumentEventMap]?: keyof typeof eventMap diff --git a/src/event/index.ts b/src/event/index.ts index ea96faa1..9cce9b19 100644 --- a/src/event/index.ts +++ b/src/event/index.ts @@ -1,8 +1,8 @@ import {Config} from '../setup' -import {createEvent, EventTypeInit} from './createEvent' +import {createEvent} from './createEvent' import {dispatchEvent} from './dispatchEvent' import {isKeyboardEvent, isMouseEvent} from './eventMap' -import {EventType, PointerCoords} from './types' +import {EventType, EventTypeInit, PointerCoords} from './types' export type {EventType, PointerCoords} diff --git a/src/event/types.ts b/src/event/types.ts index 8d02a8c9..34bae61f 100644 --- a/src/event/types.ts +++ b/src/event/types.ts @@ -1,5 +1,27 @@ export type EventType = keyof DocumentEventMap +export type EventTypeInit = SpecificEventInit< + FixedDocumentEventMap[K] +> + +export interface FixedDocumentEventMap extends DocumentEventMap { + input: InputEvent +} + +type SpecificEventInit = E extends InputEvent + ? InputEventInit + : E extends ClipboardEvent + ? ClipboardEventInit + : E extends KeyboardEvent + ? KeyboardEventInit + : E extends PointerEvent + ? PointerEventInit + : E extends MouseEvent + ? MouseEventInit + : E extends UIEvent + ? UIEventInit + : EventInit + export interface PointerCoords { x?: number y?: number diff --git a/tests/_helpers/listeners.ts b/tests/_helpers/listeners.ts index 334f34c6..96715e8b 100644 --- a/tests/_helpers/listeners.ts +++ b/tests/_helpers/listeners.ts @@ -164,6 +164,8 @@ function isMouseEvent(event: Event): event is MouseEvent { return ( event.constructor.name === 'MouseEvent' || event.type === 'click' || + event.type === 'auxclick' || + event.type === 'contextmenu' || event.type.startsWith('mouse') ) } diff --git a/tests/keyboard/modifiers.ts b/tests/keyboard/modifiers.ts index 600566f1..3fb38500 100644 --- a/tests/keyboard/modifiers.ts +++ b/tests/keyboard/modifiers.ts @@ -12,8 +12,7 @@ test.each([ const modifierDown = getEvents('keydown')[0] expect(modifierDown).toHaveProperty('key', key) expect(modifierDown).toHaveProperty(modifier, true) - // This should be true, but this is a bug in JSDOM - // expect(modifierDown.getModifierState(key)).toBe(true) + expect(modifierDown.getModifierState(key)).toBe(true) await user.keyboard('a') expect(getEvents('keydown')[1]).toHaveProperty(modifier, true)