From 168d6f12f7b874805faa6fc10f9f98adc492b0d4 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 7 Oct 2021 15:49:10 +0000 Subject: [PATCH 1/2] refactor: convert test setup to Typescript --- src/__tests__/helpers/dom-event-map.d.ts | 9 + src/__tests__/helpers/{utils.js => utils.ts} | 224 +++++++++++++------ 2 files changed, 170 insertions(+), 63 deletions(-) create mode 100644 src/__tests__/helpers/dom-event-map.d.ts rename src/__tests__/helpers/{utils.js => utils.ts} (59%) diff --git a/src/__tests__/helpers/dom-event-map.d.ts b/src/__tests__/helpers/dom-event-map.d.ts new file mode 100644 index 00000000..ed4007a3 --- /dev/null +++ b/src/__tests__/helpers/dom-event-map.d.ts @@ -0,0 +1,9 @@ +declare module '@testing-library/dom/dist/event-map' { + export const eventMap: Record< + string, + { + EventType: string + defaultInit: EventInit + } + > +} diff --git a/src/__tests__/helpers/utils.js b/src/__tests__/helpers/utils.ts similarity index 59% rename from src/__tests__/helpers/utils.js rename to src/__tests__/helpers/utils.ts index 36a4e474..5f0c36fb 100644 --- a/src/__tests__/helpers/utils.js +++ b/src/__tests__/helpers/utils.ts @@ -7,25 +7,48 @@ import {isElementType} from '../../utils' // all of the stuff below is complex magic that makes the simpler tests work // sorrynotsorry... -const unstringSnapshotSerializer = { - test: val => val && val.hasOwnProperty('snapshot'), - print: val => val.snapshot, +const unstringSnapshotSerializer: jest.SnapshotSerializerPlugin = { + test: (val: unknown) => + Boolean( + typeof val === 'object' + ? Object.prototype.hasOwnProperty.call(val, 'snapshot') + : false, + ), + print: val => String((val)?.snapshot), } expect.addSnapshotSerializer(unstringSnapshotSerializer) -function setup(ui, {eventHandlers} = {}) { +type EventHandlers = Record + +// The HTMLCollection in lib.d.ts does not allow array access +type HTMLCollection> = Elements & { + item(i: N): Elements[N] +} + +function setup( + ui: string, + { + eventHandlers, + }: { + eventHandlers?: EventHandlers + } = {}, +) { const div = document.createElement('div') div.innerHTML = ui.trim() document.body.append(div) + type ElementsArray = Elements extends Array ? Elements : [Elements] return { - element: div.firstChild, - elements: div.children, + element: div.firstChild as ElementsArray[0], + elements: div.children as unknown as HTMLCollection, // for single elements add the listeners to the element for capturing non-bubbling events - ...addListeners(div.children.length === 1 ? div.firstChild : div, { - eventHandlers, - }), + ...addListeners( + div.children.length === 1 ? (div.firstChild as Element) : div, + { + eventHandlers, + }, + ), } } @@ -49,7 +72,7 @@ function setupSelect({ ` document.body.append(form) - const select = form.querySelector('select') + const select = form.querySelector('select') as HTMLSelectElement const options = Array.from(form.querySelectorAll('option')) return { ...addListeners(select), @@ -76,17 +99,22 @@ function setupListbox() { ` document.body.append(wrapper) - const listbox = wrapper.querySelector('[role="listbox"]') - const options = Array.from(wrapper.querySelectorAll('[role="option"]')) + const listbox = wrapper.querySelector('[role="listbox"]') as HTMLUListElement + const options = Array.from( + wrapper.querySelectorAll('[role="option"]'), + ) // the user is responsible for handling aria-selected on listbox options options.forEach(el => - el.addEventListener('click', e => - e.target.setAttribute( + el.addEventListener('click', e => { + const target = e.currentTarget as HTMLElement + target.setAttribute( 'aria-selected', - JSON.stringify(!JSON.parse(e.target.getAttribute('aria-selected'))), - ), - ), + JSON.stringify( + !JSON.parse(String(target.getAttribute('aria-selected'))), + ), + ) + }), ) return { @@ -97,7 +125,7 @@ function setupListbox() { } const eventLabelGetters = { - KeyboardEvent(event) { + KeyboardEvent(event: KeyboardEvent) { return [ event.key, typeof event.keyCode === 'undefined' ? null : `(${event.keyCode})`, @@ -105,9 +133,9 @@ const eventLabelGetters = { .join(' ') .trim() }, - MouseEvent(event) { + MouseEvent(event: MouseEvent) { // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button - const mouseButtonMap = { + const mouseButtonMap: Record = { 0: 'Left', 1: 'Middle', 2: 'Right', @@ -116,20 +144,29 @@ const eventLabelGetters = { } return `${mouseButtonMap[event.button]} (${event.button})` }, -} +} as const -let eventListeners = [] +let eventListeners: Array<{ + el: EventTarget + type: string + listener: EventListener +}> = [] // asside from the hijacked listener stuff here, it's also important to call // this function rather than simply calling addEventListener yourself // because it adds your listener to an eventListeners array which is cleaned // up automatically which will help use avoid memory leaks. -function addEventListener(el, type, listener, options) { +function addEventListener( + el: EventTarget, + type: string, + listener: EventListener, + options?: AddEventListenerOptions, +) { eventListeners.push({el, type, listener}) el.addEventListener(type, listener, options) } -function getElementValue(element) { +function getElementValue(element: Element) { if (isElementType(element, 'select') && element.multiple) { return JSON.stringify(Array.from(element.selectedOptions).map(o => o.value)) } else if (element.getAttribute('role') === 'listbox') { @@ -139,25 +176,38 @@ function getElementValue(element) { } else if (element.getAttribute('role') === 'option') { return JSON.stringify(element.innerHTML) } else if ( - element.type === 'checkbox' || - element.type === 'radio' || + isElementType(element, 'input', {type: 'checkbox'}) || + isElementType(element, 'input', {type: 'radio'}) || isElementType(element, 'button') ) { // handled separately return null } - return JSON.stringify(element.value) + return JSON.stringify((element as HTMLInputElement).value) } -function getElementDisplayName(element) { +function hasProperty( + obj: T, + prop: K, +): obj is T & {[k in K]: unknown} { + return prop in obj +} + +function getElementDisplayName(element: Element) { const value = getElementValue(element) - const hasChecked = element.type === 'checkbox' || element.type === 'radio' + const hasChecked = + isElementType(element, 'input', {type: 'checkbox'}) || + isElementType(element, 'input', {type: 'radio'}) return [ element.tagName.toLowerCase(), element.id ? `#${element.id}` : null, - element.name ? `[name="${element.name}"]` : null, - element.htmlFor ? `[for="${element.htmlFor}"]` : null, + hasProperty(element, 'name') && element.name + ? `[name="${element.name}"]` + : null, + hasProperty(element, 'htmlFor') && element.htmlFor + ? `[for="${element.htmlFor}"]` + : null, value ? `[value=${value}]` : null, hasChecked ? `[checked=${element.checked}]` : null, isElementType(element, 'option') ? `[selected=${element.selected}]` : null, @@ -169,13 +219,44 @@ function getElementDisplayName(element) { .join('') } -function addListeners(element, {eventHandlers = {}} = {}) { - const eventHandlerCalls = {current: []} +type CallData = { + event: Event + elementDisplayName: string + testData?: TestData +} + +type TestData = { + handled?: boolean + + // Where is this assigned? + before?: Element + after?: Element +} + +function isElement(target: EventTarget): target is Element { + return 'tagName' in target +} + +function isMouseEvent(event: Event): event is MouseEvent { + return event.constructor.name === 'MouseEvent' +} + +function addListeners( + element: Element & {testData?: TestData}, + { + eventHandlers = {}, + }: { + eventHandlers?: EventHandlers + } = {}, +) { + const eventHandlerCalls: {current: CallData[]} = {current: []} const generalListener = jest - .fn(event => { - const callData = { + .fn((event: Event) => { + const target = event.target + const callData: CallData = { event, - elementDisplayName: getElementDisplayName(event.target), + elementDisplayName: + target && isElement(target) ? getElementDisplayName(target) : '', } if (element.testData && !element.testData.handled) { callData.testData = element.testData @@ -192,10 +273,9 @@ function addListeners(element, {eventHandlers = {}} = {}) { for (const name of listeners) { addEventListener(element, name.toLowerCase(), (...args) => { - const handler = eventHandlers[name] - if (handler) { + if (name in eventHandlers) { generalListener(...args) - return handler(...args) + return eventHandlers[name](...args) } return generalListener(...args) }) @@ -208,10 +288,15 @@ function addListeners(element, {eventHandlers = {}} = {}) { function getEventSnapshot() { const eventCalls = eventHandlerCalls.current .map(({event, testData, elementDisplayName}) => { + const eventName = event.constructor.name const eventLabel = - eventLabelGetters[event.constructor.name]?.(event) ?? '' + eventName in eventLabelGetters + ? eventLabelGetters[eventName as keyof typeof eventLabelGetters]( + event as KeyboardEvent & MouseEvent, + ) + : '' const modifiers = ['altKey', 'shiftKey', 'metaKey', 'ctrlKey'] - .filter(key => event[key]) + .filter(key => event[key as keyof Event]) .map(k => `{${k.replace('Key', '')}}`) .join('') @@ -245,18 +330,17 @@ function addListeners(element, {eventHandlers = {}} = {}) { generalListener.mockClear() eventHandlerCalls.current = [] } - const getEvents = type => + const getEvents = (type?: string) => generalListener.mock.calls .map(([e]) => e) .filter(e => !type || e.type === type) - const eventWasFired = eventType => getEvents().some(e => e.type === eventType) + const eventWasFired = (eventType: string) => getEvents(eventType).length > 0 function getClickEventsSnapshot() { - const lines = getEvents().map( - ({constructor, type, button, buttons, detail}) => - constructor.name === 'MouseEvent' - ? `${type} - button=${button}; buttons=${buttons}; detail=${detail}` - : type, + const lines = getEvents().map(e => + isMouseEvent(e) + ? `${e.type} - button=${e.button}; buttons=${e.buttons}; detail=${e.detail}` + : e.type, ) return {snapshot: lines.join('\n')} } @@ -270,21 +354,23 @@ function addListeners(element, {eventHandlers = {}} = {}) { } } -function getValueWithSelection({value, selectionStart, selectionEnd}) { +function getValueWithSelection(element?: Element) { + const {value, selectionStart, selectionEnd} = element as HTMLInputElement + return [ - value.slice(0, selectionStart), + value.slice(0, selectionStart ?? undefined), ...(selectionStart === selectionEnd ? ['{CURSOR}'] : [ '{SELECTION}', - value.slice(selectionStart, selectionEnd), + value.slice(selectionStart ?? 0, selectionEnd ?? undefined), '{/SELECTION}', ]), - value.slice(selectionEnd), + value.slice(selectionEnd ?? undefined), ].join('') } -const changeLabelGetter = { +const changeLabelGetter: Record string> = { value: ({before, after}) => [ JSON.stringify(getValueWithSelection(before)), @@ -292,8 +378,8 @@ const changeLabelGetter = { ].join(' -> '), checked: ({before, after}) => [ - before.checked ? 'checked' : 'unchecked', - after.checked ? 'checked' : 'unchecked', + (before as HTMLInputElement).checked ? 'checked' : 'unchecked', + (after as HTMLInputElement).checked ? 'checked' : 'unchecked', ].join(' -> '), // unfortunately, changing a select option doesn't happen within fireEvent @@ -303,16 +389,28 @@ const changeLabelGetter = { } changeLabelGetter.selectionStart = changeLabelGetter.value changeLabelGetter.selectionEnd = changeLabelGetter.value -const getDefaultLabel = ({key, before, after}) => - `${key}: ${JSON.stringify(before[key])} -> ${JSON.stringify(after[key])}` -function getChanges({before, after}) { +const getDefaultLabel = ({ + key, + before, + after, +}: { + key: keyof T + before: T + after: T +}) => `${key}: ${JSON.stringify(before[key])} -> ${JSON.stringify(after[key])}` + +function getChanges({before, after}: TestData) { const changes = new Set() - for (const key of Object.keys(before)) { - if (after[key] !== before[key]) { - changes.add( - (changeLabelGetter[key] ?? getDefaultLabel)({key, before, after}), - ) + if (before && after) { + for (const key of Object.keys(before) as Array) { + if (after[key] !== before[key]) { + changes.add( + (key in changeLabelGetter ? changeLabelGetter[key] : getDefaultLabel)( + {key, before, after}, + ), + ) + } } } From 3f8d48e7448540600e739feb4d24951e90d713f9 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 7 Oct 2021 16:02:31 +0000 Subject: [PATCH 2/2] refactor: remove obsolete type casts and optinal chains --- src/__tests__/keyboard/index.ts | 6 ++-- .../keyboard/keyboardImplementation.ts | 2 +- src/__tests__/keyboard/plugin/arrow.ts | 2 +- src/__tests__/keyboard/plugin/character.ts | 11 +++--- src/__tests__/keyboard/plugin/control.ts | 34 +++++++++---------- src/__tests__/keyboard/plugin/functional.ts | 16 +++++---- .../keyboard/shared/fireInputEvent.ts | 8 ++--- src/__tests__/utils/edit/calculateNewValue.ts | 10 +++--- src/__tests__/utils/edit/selectionRange.ts | 30 ++++++++-------- src/__tests__/utils/misc/hasPointerEvents.ts | 8 ++--- 10 files changed, 63 insertions(+), 64 deletions(-) diff --git a/src/__tests__/keyboard/index.ts b/src/__tests__/keyboard/index.ts index 4b573940..3eacd272 100644 --- a/src/__tests__/keyboard/index.ts +++ b/src/__tests__/keyboard/index.ts @@ -26,7 +26,7 @@ it('type without focus', () => { it('type with focus', () => { const {element} = setup('') const {getEventSnapshot} = addListeners(document.body) - ;(element as HTMLInputElement).focus() + element.focus() userEvent.keyboard('foo') @@ -53,7 +53,7 @@ it('type with focus', () => { it('type asynchronous', async () => { const {element} = setup('') const {getEventSnapshot} = addListeners(document.body) - ;(element as HTMLInputElement).focus() + element.focus() // eslint-disable-next-line testing-library/no-await-sync-events await userEvent.keyboard('foo', {delay: 1}) @@ -110,7 +110,7 @@ describe('error', () => { it('continue typing with state', () => { const {element, getEventSnapshot, clearEventCalls} = setup('') - ;(element as HTMLInputElement).focus() + element.focus() clearEventCalls() const state = userEvent.keyboard('[ShiftRight>]') diff --git a/src/__tests__/keyboard/keyboardImplementation.ts b/src/__tests__/keyboard/keyboardImplementation.ts index ef3b4f1b..be9d8329 100644 --- a/src/__tests__/keyboard/keyboardImplementation.ts +++ b/src/__tests__/keyboard/keyboardImplementation.ts @@ -3,7 +3,7 @@ import {setup} from '../helpers/utils' test('no character input if `altKey` or `ctrlKey` is pressed', () => { const {element, eventWasFired} = setup(``) - ;(element as HTMLInputElement).focus() + element.focus() userEvent.keyboard('[ControlLeft>]g') diff --git a/src/__tests__/keyboard/plugin/arrow.ts b/src/__tests__/keyboard/plugin/arrow.ts index 3dea28c1..009744ab 100644 --- a/src/__tests__/keyboard/plugin/arrow.ts +++ b/src/__tests__/keyboard/plugin/arrow.ts @@ -2,7 +2,7 @@ import userEvent from 'index' import {setup} from '__tests__/helpers/utils' const setupInput = () => - setup(``).element as HTMLInputElement + setup(``).element test('collapse selection to the left', () => { const el = setupInput() diff --git a/src/__tests__/keyboard/plugin/character.ts b/src/__tests__/keyboard/plugin/character.ts index 424c68f1..c8b6b1f5 100644 --- a/src/__tests__/keyboard/plugin/character.ts +++ b/src/__tests__/keyboard/plugin/character.ts @@ -4,10 +4,7 @@ import {setup} from '__tests__/helpers/utils' test('type [Enter] in textarea', () => { const {element, getEvents} = setup(``) - userEvent.type( - element as HTMLTextAreaElement, - 'oo[Enter]bar[ShiftLeft>][Enter]baz', - ) + userEvent.type(element, 'oo[Enter]bar[ShiftLeft>][Enter]baz') expect(element).toHaveValue('foo\nbar\nbaz') expect(getEvents('input')[2]).toHaveProperty('inputType', 'insertLineBreak') @@ -16,12 +13,12 @@ test('type [Enter] in textarea', () => { test('type [Enter] in contenteditable', () => { const {element, getEvents} = setup(`
f
`) - ;(element as HTMLDivElement).focus() + element.focus() userEvent.keyboard('oo[Enter]bar[ShiftLeft>][Enter]baz') expect(element).toHaveTextContent('foo bar baz') - expect(element?.firstChild).toHaveProperty('nodeValue', 'foo\nbar\nbaz') + expect(element.firstChild).toHaveProperty('nodeValue', 'foo\nbar\nbaz') expect(getEvents('input')[2]).toHaveProperty('inputType', 'insertParagraph') expect(getEvents('input')[6]).toHaveProperty('inputType', 'insertLineBreak') }) @@ -36,7 +33,7 @@ test.each([ 'type invalid values into ', (text, expectedValue, expectedCarryValue, expectedInputEvents) => { const {element, getEvents} = setup(``) - ;(element as HTMLInputElement).focus() + element.focus() const state = userEvent.keyboard(text) diff --git a/src/__tests__/keyboard/plugin/control.ts b/src/__tests__/keyboard/plugin/control.ts index 224bcf6f..99699fa4 100644 --- a/src/__tests__/keyboard/plugin/control.ts +++ b/src/__tests__/keyboard/plugin/control.ts @@ -2,10 +2,12 @@ import userEvent from 'index' import {setup} from '__tests__/helpers/utils' test('press [Home] in textarea', () => { - const {element} = setup(``) - ;(element as HTMLTextAreaElement).setSelectionRange(2, 4) + const {element} = setup( + ``, + ) + element.setSelectionRange(2, 4) - userEvent.type(element as HTMLTextAreaElement, '[Home]') + userEvent.type(element, '[Home]') expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 0) @@ -13,22 +15,22 @@ test('press [Home] in textarea', () => { test('press [Home] in contenteditable', () => { const {element} = setup(`
foo\nbar\baz
`) - document - .getSelection() - ?.setPosition((element as HTMLDivElement).firstChild, 2) + document.getSelection()?.setPosition(element.firstChild, 2) - userEvent.type(element as HTMLTextAreaElement, '[Home]') + userEvent.type(element, '[Home]') const selection = document.getSelection() - expect(selection).toHaveProperty('focusNode', element?.firstChild) + expect(selection).toHaveProperty('focusNode', element.firstChild) expect(selection).toHaveProperty('focusOffset', 0) }) test('press [End] in textarea', () => { - const {element} = setup(``) - ;(element as HTMLTextAreaElement).setSelectionRange(2, 4) + const {element} = setup( + ``, + ) + element.setSelectionRange(2, 4) - userEvent.type(element as HTMLTextAreaElement, '[End]') + userEvent.type(element, '[End]') expect(element).toHaveProperty('selectionStart', 10) expect(element).toHaveProperty('selectionEnd', 10) @@ -36,14 +38,12 @@ test('press [End] in textarea', () => { test('press [End] in contenteditable', () => { const {element} = setup(`
foo\nbar\baz
`) - document - .getSelection() - ?.setPosition((element as HTMLDivElement).firstChild, 2) + document.getSelection()?.setPosition(element.firstChild, 2) - userEvent.type(element as HTMLTextAreaElement, '[End]') + userEvent.type(element, '[End]') const selection = document.getSelection() - expect(selection).toHaveProperty('focusNode', element?.firstChild) + expect(selection).toHaveProperty('focusNode', element.firstChild) expect(selection).toHaveProperty('focusOffset', 10) }) @@ -51,7 +51,7 @@ test('use [Delete] on number input', () => { const {element} = setup(``) userEvent.type( - element as HTMLInputElement, + element, '1e-5[ArrowLeft][Delete]6[ArrowLeft][ArrowLeft][ArrowLeft][Delete][Delete]', ) diff --git a/src/__tests__/keyboard/plugin/functional.ts b/src/__tests__/keyboard/plugin/functional.ts index 51047b26..cc72f8e0 100644 --- a/src/__tests__/keyboard/plugin/functional.ts +++ b/src/__tests__/keyboard/plugin/functional.ts @@ -32,7 +32,7 @@ test('produce extra events for the Control key when AltGraph is pressed', () => test('backspace to valid value', () => { const {element, getEventSnapshot} = setup(``) - userEvent.type(element as Element, '5e-[Backspace][Backspace]') + userEvent.type(element, '5e-[Backspace][Backspace]') expect(element).toHaveValue(5) expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -76,7 +76,7 @@ test('trigger click event on [Enter] keydown on HTMLAnchorElement', () => { const {element, getEventSnapshot, getEvents} = setup( ``, ) - ;(element as HTMLAnchorElement).focus() + element.focus() userEvent.keyboard('[Enter]') @@ -99,7 +99,7 @@ test('trigger click event on [Enter] keydown on HTMLAnchorElement', () => { test('trigger click event on [Enter] keypress on HTMLButtonElement', () => { const {element, getEventSnapshot, getEvents} = setup(`