From ff8261c988784291859cb19d36244b308cd1a697 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 10 Jun 2020 22:19:02 -0600 Subject: [PATCH 01/31] feat(userEvent): build-in @testing-library/user-event --- src/user-event/__tests__/clear.js | 72 +++ src/user-event/__tests__/click.js | 400 ++++++++++++++ src/user-event/__tests__/dblclick.js | 309 +++++++++++ .../__tests__/helpers/customElement.js | 35 ++ src/user-event/__tests__/helpers/utils.js | 269 ++++++++++ src/user-event/__tests__/hover.js | 15 + src/user-event/__tests__/selectoptions.js | 79 +++ src/user-event/__tests__/tab.js | 329 ++++++++++++ .../__tests__/toggleselectoptions.js | 103 ++++ src/user-event/__tests__/type-modifiers.js | 317 +++++++++++ src/user-event/__tests__/type.js | 466 +++++++++++++++++ src/user-event/__tests__/unhover.js | 14 + src/user-event/__tests__/upload.js | 93 ++++ src/user-event/index.js | 491 ++++++++++++++++++ src/user-event/tick.js | 54 ++ src/user-event/type.js | 440 ++++++++++++++++ 16 files changed, 3486 insertions(+) create mode 100644 src/user-event/__tests__/clear.js create mode 100644 src/user-event/__tests__/click.js create mode 100644 src/user-event/__tests__/dblclick.js create mode 100644 src/user-event/__tests__/helpers/customElement.js create mode 100644 src/user-event/__tests__/helpers/utils.js create mode 100644 src/user-event/__tests__/hover.js create mode 100644 src/user-event/__tests__/selectoptions.js create mode 100644 src/user-event/__tests__/tab.js create mode 100644 src/user-event/__tests__/toggleselectoptions.js create mode 100644 src/user-event/__tests__/type-modifiers.js create mode 100644 src/user-event/__tests__/type.js create mode 100644 src/user-event/__tests__/unhover.js create mode 100644 src/user-event/__tests__/upload.js create mode 100644 src/user-event/index.js create mode 100644 src/user-event/tick.js create mode 100644 src/user-event/type.js diff --git a/src/user-event/__tests__/clear.js b/src/user-event/__tests__/clear.js new file mode 100644 index 00000000..b3183f00 --- /dev/null +++ b/src/user-event/__tests__/clear.js @@ -0,0 +1,72 @@ +import userEvent from '..' +import {setup} from './helpers/utils' + +test('clears text', () => { + const {element, getEventCalls} = setup('') + userEvent.clear(element) + expect(element).toHaveValue('') + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: Left (0) + mousedown: Left (0) + mouseup: Left (0) + click: Left (0) + dblclick: Left (0) + keydown: Backspace (8) + keyup: Backspace (8) + input: "{SELECTION}hello{/SELECTION}" -> "hello" + change + `) +}) + +test('does not clear text on disabled inputs', () => { + const {element, getEventCalls} = setup('') + userEvent.clear(element) + expect(element).toHaveValue('hello') + expect(getEventCalls()).toMatchInlineSnapshot( + `No events were fired on: input[value="hello"]`, + ) +}) + +test('does not clear text on readonly inputs', () => { + const {element, getEventCalls} = setup('') + userEvent.clear(element) + expect(element).toHaveValue('hello') + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="hello"] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: Left (0) + mousedown: Left (0) + mouseup: Left (0) + click: Left (0) + dblclick: Left (0) + keydown: Backspace (8) + keyup: Backspace (8) + `) +}) + +test('clears even on inputs that cannot (programmatically) have a selection', () => { + const {element: email} = setup('') + userEvent.clear(email) + expect(email).toHaveValue('') + + const {element: password} = setup('') + userEvent.clear(password) + expect(password).toHaveValue('') + + const {element: number} = setup('') + userEvent.clear(number) + // jest-dom does funny stuff with toHaveValue on number inputs + expect(number.value).toBe('') +}) diff --git a/src/user-event/__tests__/click.js b/src/user-event/__tests__/click.js new file mode 100644 index 00000000..3e2f628d --- /dev/null +++ b/src/user-event/__tests__/click.js @@ -0,0 +1,400 @@ +import userEvent from '..' +import {setup, addEventListener, addListeners} from './helpers/utils' + +test('click in input', () => { + const {element, getEventCalls} = setup('') + userEvent.click(element) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: Left (0) + `) +}) + +test('click in textarea', () => { + const {element, getEventCalls} = setup('') + userEvent.click(element) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: textarea[value=""] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: Left (0) + `) +}) + +test('should fire the correct events for ', () => { + const {element, getEventCalls} = setup('') + expect(element).not.toBeChecked() + userEvent.click(element) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[checked=true] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: unchecked -> checked + input: checked + change + `) +}) + +test('should fire the correct events for ', () => { + const {element, getEventCalls} = setup('') + userEvent.click(element) + expect(element).toBeDisabled() + // no event calls is expected here: + expect(getEventCalls()).toMatchInlineSnapshot( + `No events were fired on: input[checked=false]`, + ) + expect(element).toBeDisabled() + expect(element).toHaveProperty('checked', false) +}) + +test('should fire the correct events for ', () => { + const {element, getEventCalls} = setup('') + expect(element).not.toBeChecked() + userEvent.click(element) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[checked=true] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: unchecked -> checked + input: checked + change + `) + + expect(element).toHaveProperty('checked', true) +}) + +test('should fire the correct events for ', () => { + const {element, getEventCalls} = setup('') + userEvent.click(element) + expect(element).toBeDisabled() + // no event calls is expected here: + expect(getEventCalls()).toMatchInlineSnapshot( + `No events were fired on: input[checked=false]`, + ) + expect(element).toBeDisabled() + + expect(element).toHaveProperty('checked', false) +}) + +test('should fire the correct events for
', () => { + const {element, getEventCalls} = setup('
') + userEvent.click(element) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: div + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + mouseup: Left (0) + click: Left (0) + `) +}) + +test('toggles the focus', () => { + const {element} = setup(`
`) + + const a = element.children[0] + const b = element.children[1] + + expect(a).not.toHaveFocus() + expect(b).not.toHaveFocus() + + userEvent.click(a) + expect(a).toHaveFocus() + expect(b).not.toHaveFocus() + + userEvent.click(b) + expect(a).not.toHaveFocus() + expect(b).toHaveFocus() +}) + +test('should blur the previous element', () => { + const {element} = setup(`
`) + + const a = element.children[0] + const b = element.children[1] + + const {getEventCalls, clearEventCalls} = addListeners(a) + + userEvent.click(a) + clearEventCalls() + userEvent.click(b) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + mousemove: Left (0) (bubbled from input[value=""]) + mouseleave: Left (0) + blur + `) +}) + +test('should not blur the previous element when mousedown prevents default', () => { + const {element} = setup(`
`) + + const a = element.children[0] + const b = element.children[1] + + addEventListener(b, 'mousedown', e => e.preventDefault()) + + const {getEventCalls, clearEventCalls} = addListeners(a) + + userEvent.click(a) + clearEventCalls() + userEvent.click(b) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + mousemove: Left (0) (bubbled from input[value=""]) + mouseleave: Left (0) + `) +}) + +test('does not lose focus when click updates focus', () => { + const {element} = setup(`
`) + const input = element.children[0] + const button = element.children[1] + + addEventListener(button, 'click', () => input.focus()) + + expect(input).not.toHaveFocus() + + userEvent.click(button) + expect(input).toHaveFocus() + + userEvent.click(button) + expect(input).toHaveFocus() +}) + +test('gives focus to the form control when clicking the label', () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const input = element.children[1] + + userEvent.click(label) + expect(input).toHaveFocus() +}) + +test('gives focus to the form control when clicking within a label', () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const span = label.firstChild + const input = element.children[1] + + userEvent.click(span) + expect(input).toHaveFocus() +}) + +test('clicking a label checks the checkbox', () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const input = element.children[1] + + userEvent.click(label) + expect(input).toHaveFocus() + expect(input).toBeChecked() +}) + +test('clicking a label checks the radio', () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const input = element.children[1] + + userEvent.click(label) + expect(input).toHaveFocus() + expect(input).toBeChecked() +}) + +test('submits a form when clicking on a `) + userEvent.click(element.children[0]) + expect(getEventCalls()).toContain('submit') +}) + +test('does not submit a form when clicking on a + + `) + userEvent.click(element.children[0]) + expect(getEventCalls()).not.toContain('submit') +}) + +test('does not fire blur on current element if is the same as previous', () => { + const {element, getEventCalls, clearEventCalls} = setup('
+ `) + + const a = element.children[0] + const b = element.children[1] + + const {getEventCalls, clearEventCalls} = addListeners(a) + + userEvent.dblClick(a) + clearEventCalls() + userEvent.dblClick(b) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: button#button-a + + mousemove: Left (0) (bubbled from button#button-a) + mouseleave: Left (0) + blur + `) +}) + +test('does not blur the previous element when mousedown prevents default', () => { + const {element} = setup(` +
+
+ `) + + const a = element.children[0] + const b = element.children[1] + + addEventListener(b, 'mousedown', e => e.preventDefault()) + + const {getEventCalls, clearEventCalls} = addListeners(a) + + userEvent.dblClick(a) + clearEventCalls() + userEvent.dblClick(b) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: button#button-a + + mousemove: Left (0) (bubbled from button#button-a) + mouseleave: Left (0) + `) +}) + +test('fires mouse events with the correct properties', () => { + const {element, getEvents} = setup('
') + userEvent.dblClick(element) + expect(getEvents()).toEqual([ + expect.objectContaining({ + type: 'mouseover', + button: 0, + buttons: 0, + detail: 0, + }), + expect.objectContaining({ + type: 'mousemove', + button: 0, + buttons: 0, + detail: 0, + }), + expect.objectContaining({ + type: 'mousedown', + button: 0, + buttons: 1, + detail: 1, + }), + expect.objectContaining({ + type: 'mouseup', + button: 0, + buttons: 1, + detail: 1, + }), + expect.objectContaining({ + type: 'click', + button: 0, + buttons: 1, + detail: 1, + }), + expect.objectContaining({ + type: 'mousedown', + button: 0, + buttons: 1, + detail: 2, + }), + expect.objectContaining({ + type: 'mouseup', + button: 0, + buttons: 1, + detail: 2, + }), + expect.objectContaining({ + type: 'click', + button: 0, + buttons: 1, + detail: 2, + }), + expect.objectContaining({ + type: 'dblclick', + button: 0, + buttons: 1, + detail: 2, + }), + ]) +}) + +test('fires mouse events with custom button property', () => { + const {element, getEvents} = setup('
') + userEvent.dblClick(element, { + button: 1, + altKey: true, + }) + expect(getEvents()).toEqual([ + expect.objectContaining({ + type: 'mouseover', + button: 0, + buttons: 0, + detail: 0, + altKey: true, + }), + expect.objectContaining({ + type: 'mousemove', + button: 0, + buttons: 0, + detail: 0, + altKey: true, + }), + expect.objectContaining({ + type: 'mousedown', + button: 1, + buttons: 4, + detail: 1, + altKey: true, + }), + expect.objectContaining({ + type: 'mouseup', + button: 1, + buttons: 4, + detail: 1, + altKey: true, + }), + expect.objectContaining({ + type: 'click', + button: 1, + buttons: 4, + detail: 1, + altKey: true, + }), + expect.objectContaining({ + type: 'mousedown', + button: 1, + buttons: 4, + detail: 2, + altKey: true, + }), + expect.objectContaining({ + type: 'mouseup', + button: 1, + buttons: 4, + detail: 2, + altKey: true, + }), + expect.objectContaining({ + type: 'click', + button: 1, + buttons: 4, + detail: 2, + altKey: true, + }), + expect.objectContaining({ + type: 'dblclick', + button: 1, + buttons: 4, + detail: 2, + altKey: true, + }), + ]) +}) + +test('fires mouse events with custom buttons property', () => { + const {element, getEvents} = setup('
') + + userEvent.dblClick(element, {buttons: 4}) + + expect(getEvents()).toEqual([ + expect.objectContaining({ + type: 'mouseover', + button: 0, + buttons: 0, + detail: 0, + }), + expect.objectContaining({ + type: 'mousemove', + button: 0, + buttons: 0, + detail: 0, + }), + expect.objectContaining({ + type: 'mousedown', + button: 1, + buttons: 4, + detail: 1, + }), + expect.objectContaining({ + type: 'mouseup', + button: 1, + buttons: 4, + detail: 1, + }), + expect.objectContaining({ + type: 'click', + button: 1, + buttons: 4, + detail: 1, + }), + expect.objectContaining({ + type: 'mousedown', + button: 1, + buttons: 4, + detail: 2, + }), + expect.objectContaining({ + type: 'mouseup', + button: 1, + buttons: 4, + detail: 2, + }), + expect.objectContaining({ + type: 'click', + button: 1, + buttons: 4, + detail: 2, + }), + expect.objectContaining({ + type: 'dblclick', + button: 1, + buttons: 4, + detail: 2, + }), + ]) +}) diff --git a/src/user-event/__tests__/helpers/customElement.js b/src/user-event/__tests__/helpers/customElement.js new file mode 100644 index 00000000..e3d1c801 --- /dev/null +++ b/src/user-event/__tests__/helpers/customElement.js @@ -0,0 +1,35 @@ +const observed = ['value'] + +class CustomEl extends HTMLElement { + static getObservedAttributes() { + return observed + } + + constructor() { + super() + this.attachShadow({mode: 'open'}) + this.shadowRoot.innerHTML = `` + this.$input = this.shadowRoot.querySelector('input') + } + + connectedCallback() { + observed.forEach(name => { + this.render(name, this.getAttribute(name)) + }) + } + + attributeChangedCallback(name, oldVal, newVal) { + if (oldVal === newVal) return + this.render(name, newVal) + } + + render(name, value) { + if (value == null) { + this.$input.removeAttribute(name) + } else { + this.$input.setAttribute(name, value) + } + } +} + +customElements.define('custom-el', CustomEl) diff --git a/src/user-event/__tests__/helpers/utils.js b/src/user-event/__tests__/helpers/utils.js new file mode 100644 index 00000000..3ebdbb60 --- /dev/null +++ b/src/user-event/__tests__/helpers/utils.js @@ -0,0 +1,269 @@ +// this is pretty helpful: +// https://jsbin.com/nimelileyo/edit?js,output + +// all of the stuff below is complex magic that makes the simpler tests work +// sorrynotsorry... + +const unstringSnapshotSerializer = { + test: val => typeof val === 'string', + print: val => val, +} + +expect.addSnapshotSerializer(unstringSnapshotSerializer) + +function setup(ui, {eventHandlers} = {}) { + const div = document.createElement('div') + div.innerHTML = ui.trim() + document.body.append(div) + + const element = div.firstChild + + return {element, ...addListeners(element, {eventHandlers})} +} + +function setupSelect({multiple = false} = {}) { + const form = document.createElement('form') + form.innerHTML = ` + + ` + document.body.append(form) + const select = form.querySelector('select') + const options = Array.from(form.querySelectorAll('option')) + return { + ...addListeners(select), + form, + select, + options, + } +} + +let eventListeners = [] + +function getTestData(element, event) { + return { + bubbledFrom: + event && event.eventPhase === event.BUBBLING_PHASE + ? getElementDisplayName(event.target) + : null, + value: element.value, + selectionStart: element.selectionStart, + selectionEnd: element.selectionEnd, + checked: element.checked, + } +} + +// 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) { + el.previousTestData = getTestData(el) + const hijackedListener = e => { + e.testData = {previous: e.target.previousTestData} + const retVal = listener(e) + const next = getTestData(e.target, e) + e.testData.next = next + e.target.previousTestData = next + return retVal + } + eventListeners.push({el, type, listener: hijackedListener}) + el.addEventListener(type, hijackedListener, options) +} + +function getElementValue(element) { + if (element.tagName === 'SELECT' && element.multiple) { + return JSON.stringify(Array.from(element.selectedOptions).map(o => o.value)) + } else if ( + element.type === 'checkbox' || + element.type === 'radio' || + element.tagName === 'BUTTON' + ) { + // handled separately + return null + } + return JSON.stringify(element.value) +} + +function getElementDisplayName(element) { + const value = getElementValue(element) + const hasChecked = element.type === 'checkbox' || element.type === 'radio' + return [ + element.tagName.toLowerCase(), + element.id ? `#${element.id}` : null, + element.name ? `[name="${element.name}"]` : null, + element.htmlFor ? `[for="${element.htmlFor}"]` : null, + value ? `[value=${value}]` : null, + hasChecked ? `[checked=${element.checked}]` : null, + ] + .filter(Boolean) + .join('') +} + +function addListeners(element, {eventHandlers = {}} = {}) { + const generalListener = jest.fn().mockName('eventListener') + const listeners = [ + 'submit', + 'keydown', + 'keyup', + 'keypress', + 'input', + 'change', + 'blur', + 'focus', + 'focusin', + 'focusout', + 'click', + 'dblclick', + 'mouseover', + 'mousemove', + 'mouseenter', + 'mouseleave', + 'mouseup', + 'mousedown', + ] + + for (const name of listeners) { + addEventListener(element, name, (...args) => { + const [, handler] = + Object.entries(eventHandlers).find( + ([key]) => key.toLowerCase() === name, + ) ?? [] + if (handler) { + generalListener(...args) + return handler(...args) + } + return generalListener(...args) + }) + } + // prevent default of submits in tests + if (element.tagName === 'FORM') { + addEventListener(element, 'submit', e => e.preventDefault()) + } + function getEventCalls() { + const eventCalls = generalListener.mock.calls + .map(([event]) => { + const window = event.target.ownerDocument.defaultView + const modifiers = ['altKey', 'shiftKey', 'metaKey', 'ctrlKey'] + .filter(key => event[key]) + .map(k => `{${k.replace('Key', '')}}`) + .join('') + + let log = event.type + if ( + event.type === 'click' && + event.hasOwnProperty('testData') && + (element.type === 'checkbox' || element.type === 'radio') + ) { + log = getCheckboxOrRadioClickedLine(event) + } else if (event.type === 'input' && event.hasOwnProperty('testData')) { + log = getInputLine(element, event) + } else if (event instanceof window.KeyboardEvent) { + log = getKeyboardEventLine(event) + } else if (event instanceof window.MouseEvent) { + log = getMouseEventLine(event) + } + + return [ + log, + event.testData && event.testData.next.bubbledFrom + ? `(bubbled from ${event.testData.next.bubbledFrom})` + : null, + modifiers, + ] + .filter(Boolean) + .join(' ') + .trim() + }) + .join('\n') + .trim() + if (eventCalls.length) { + return [ + `Events fired on: ${getElementDisplayName(element)}`, + eventCalls, + ].join('\n\n') + } else { + return `No events were fired on: ${getElementDisplayName(element)}` + } + } + const clearEventCalls = () => generalListener.mockClear() + const getEvents = () => generalListener.mock.calls.map(([e]) => e) + const eventWasFired = eventType => getEvents().some(e => e.type === eventType) + return {getEventCalls, clearEventCalls, getEvents, eventWasFired} +} + +// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button +const mouseButtonMap = { + 0: 'Left', + 1: 'Middle', + 2: 'Right', + 3: 'Browser Back', + 4: 'Browser Forward', +} +function getMouseEventLine(event) { + return [`${event.type}:`, mouseButtonMap[event.button], `(${event.button})`] + .join(' ') + .trim() +} + +function getKeyboardEventLine(event) { + return [ + `${event.type}:`, + event.key, + typeof event.keyCode === 'undefined' ? null : `(${event.keyCode})`, + ] + .join(' ') + .trim() +} + +function getCheckboxOrRadioClickedLine(event) { + const {previous, next} = event.testData + + return `${event.type}: ${previous.checked ? '' : 'un'}checked -> ${ + next.checked ? '' : 'un' + }checked` +} + +function getInputLine(element, event) { + if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { + const {previous, next} = event.testData + + if (element.type === 'checkbox' || element.type === 'radio') { + return `${event.type}: ${next.checked ? '' : 'un'}checked` + } else { + const prevVal = [ + previous.value.slice(0, previous.selectionStart), + ...(previous.selectionStart === previous.selectionEnd + ? ['{CURSOR}'] + : [ + '{SELECTION}', + previous.value.slice( + previous.selectionStart, + previous.selectionEnd, + ), + '{/SELECTION}', + ]), + previous.value.slice(previous.selectionEnd), + ].join('') + return `${event.type}: "${prevVal}" -> "${next.value}"` + } + } else { + throw new Error( + `fired ${event.type} event on a ${element.tagName} which probably doesn't make sense. Fix that, or handle it in the setup function`, + ) + } +} + +// eslint-disable-next-line jest/prefer-hooks-on-top +afterEach(() => { + for (const {el, type, listener} of eventListeners) { + el.removeEventListener(type, listener) + } + eventListeners = [] + document.body.innerHTML = '' +}) + +export {setup, setupSelect, addEventListener, addListeners} diff --git a/src/user-event/__tests__/hover.js b/src/user-event/__tests__/hover.js new file mode 100644 index 00000000..b5ab4923 --- /dev/null +++ b/src/user-event/__tests__/hover.js @@ -0,0 +1,15 @@ +import userEvent from '..' +import {setup} from './helpers/utils' + +test('hover', async () => { + const {element, getEventCalls} = setup(' + + + `) + + const [one, five] = [ + document.querySelector('[data-testid="one"]'), + document.querySelector('[data-testid="five"]'), + ] + + userEvent.tab() + expect(one).toHaveFocus() + + userEvent.tab() + expect(five).toHaveFocus() +}) + +test('should keep focus on the document if there are no enabled, focusable elements', () => { + setup(``) + userEvent.tab() + expect(document.body).toHaveFocus() + + userEvent.tab({shift: true}) + expect(document.body).toHaveFocus() +}) + +test('should respect radio groups', () => { + setup(` +
+ + + + +
`) + + const [firstLeft, firstRight, , secondRight] = document.querySelectorAll( + '[data-testid="element"]', + ) + + userEvent.tab() + + expect(firstLeft).toHaveFocus() + + userEvent.tab() + + expect(secondRight).toHaveFocus() + + userEvent.tab({shift: true}) + + expect(firstRight).toHaveFocus() +}) diff --git a/src/user-event/__tests__/toggleselectoptions.js b/src/user-event/__tests__/toggleselectoptions.js new file mode 100644 index 00000000..c57a0dd7 --- /dev/null +++ b/src/user-event/__tests__/toggleselectoptions.js @@ -0,0 +1,103 @@ +import userEvent from '..' +import {addListeners, setupSelect, setup} from './helpers/utils' + +test('should fire the correct events for multiple select', () => { + const {form, select, getEventCalls} = setupSelect({multiple: true}) + + userEvent.toggleSelectOptions(select, '1') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: select[name="select"][value=["1"]] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: Left (0) + mouseover: Left (0) (bubbled from option[value="1"]) + mousemove: Left (0) (bubbled from option[value="1"]) + mousedown: Left (0) (bubbled from option[value="1"]) + mouseup: Left (0) (bubbled from option[value="1"]) + click: Left (0) (bubbled from option[value="1"]) + change + `) + + expect(form).toHaveFormValues({select: ['1']}) +}) + +test('should fire the correct events for multiple select when focus is in other element', () => { + const {select} = setupSelect({multiple: true}) + const button = document.createElement('button') + document.body.append(button) + + const {getEventCalls: getSelectEventCalls} = addListeners(select) + const {getEventCalls: getButtonEventCalls} = addListeners(button) + + button.focus() + + userEvent.toggleSelectOptions(select, '1') + + expect(getButtonEventCalls()).toMatchInlineSnapshot(` + Events fired on: button + + focus + mousemove: Left (0) + mouseleave: Left (0) + blur + `) + expect(getSelectEventCalls()).toMatchInlineSnapshot(` + Events fired on: select[name="select"][value=["1"]] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: Left (0) + mouseover: Left (0) (bubbled from option[value="1"]) + mousemove: Left (0) (bubbled from option[value="1"]) + mousedown: Left (0) (bubbled from option[value="1"]) + mouseup: Left (0) (bubbled from option[value="1"]) + click: Left (0) (bubbled from option[value="1"]) + change + `) +}) + +test('toggle options as expected', () => { + const {element} = setup(` +
+ +
+ `) + + const select = element.querySelector('select') + + // select one + userEvent.toggleSelectOptions(select, ['1']) + expect(element).toHaveFormValues({select: ['1']}) + + // unselect one and select two + userEvent.toggleSelectOptions(select, ['1', '2']) + expect(element).toHaveFormValues({select: ['2']}) + + // // select one + userEvent.toggleSelectOptions(select, ['1']) + expect(element).toHaveFormValues({select: ['1', '2']}) +}) + +it('throws error when provided element is not a multiple select', () => { + const {element} = setup(`') + + await userEvent.type(element, '{esc}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + focus + keydown: Escape (27) + keyup: Escape (27) + `) +}) + +test('a{backspace}', async () => { + const {element, getEventCalls} = setup('') + await userEvent.type(element, 'a{backspace}') + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + focus + keydown: a (97) + keypress: a (97) + input: "{CURSOR}" -> "a" + keyup: a (97) + keydown: Backspace (8) + input: "a{CURSOR}" -> "" + keyup: Backspace (8) + `) +}) + +test('{backspace}a', async () => { + const {element, getEventCalls} = setup('') + await userEvent.type(element, '{backspace}a') + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + focus + keydown: Backspace (8) + keyup: Backspace (8) + keydown: a (97) + keypress: a (97) + input: "{CURSOR}" -> "a" + keyup: a (97) + `) +}) + +test('{backspace} triggers typing the backspace character and deletes the character behind the cursor', async () => { + const {element, getEventCalls} = setup('') + element.setSelectionRange(1, 1) + + await userEvent.type(element, '{backspace}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="o"] + + focus + keydown: Backspace (8) + input: "y{CURSOR}o" -> "o" + keyup: Backspace (8) + `) +}) + +test('{backspace} on a readOnly input', async () => { + const {element, getEventCalls} = setup('') + element.setSelectionRange(1, 1) + + await userEvent.type(element, '{backspace}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="yo"] + + focus + keydown: Backspace (8) + keyup: Backspace (8) + `) +}) + +test('{backspace} does not fire input if keydown prevents default', async () => { + const {element, getEventCalls} = setup('', { + eventHandlers: {keyDown: e => e.preventDefault()}, + }) + element.setSelectionRange(1, 1) + + await userEvent.type(element, '{backspace}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="yo"] + + focus + keydown: Backspace (8) + keyup: Backspace (8) + `) +}) + +test('{backspace} deletes the selected range', async () => { + const {element, getEventCalls} = setup('') + element.setSelectionRange(1, 5) + + await userEvent.type(element, '{backspace}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="Here"] + + focus + keydown: Backspace (8) + input: "H{SELECTION}i th{/SELECTION}ere" -> "Here" + keyup: Backspace (8) + `) +}) + +test('{backspace} on an input type that does not support selection ranges', async () => { + const {element} = setup('') + // note: you cannot even call setSelectionRange on these kinds of elements... + await userEvent.type(element, '{backspace}{backspace}a') + // removed "m" then "o" then add "a" + expect(element).toHaveValue('yo@example.ca') +}) + +test('{alt}a{/alt}', async () => { + const {element, getEventCalls} = setup('') + + await userEvent.type(element, '{alt}a{/alt}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + focus + keydown: Alt (18) {alt} + keydown: a (97) {alt} + keypress: a (97) {alt} + input: "{CURSOR}" -> "a" + keyup: a (97) {alt} + keyup: Alt (18) + `) +}) + +test('{meta}a{/meta}', async () => { + const {element, getEventCalls} = setup('') + + await userEvent.type(element, '{meta}a{/meta}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + focus + keydown: Meta (93) {meta} + keydown: a (97) {meta} + keypress: a (97) {meta} + input: "{CURSOR}" -> "a" + keyup: a (97) {meta} + keyup: Meta (93) + `) +}) + +test('{ctrl}a{/ctrl}', async () => { + const {element, getEventCalls} = setup('') + + await userEvent.type(element, '{ctrl}a{/ctrl}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + focus + keydown: Control (17) {ctrl} + keydown: a (97) {ctrl} + keypress: a (97) {ctrl} + input: "{CURSOR}" -> "a" + keyup: a (97) {ctrl} + keyup: Control (17) + `) +}) + +test('{shift}a{/shift}', async () => { + const {element, getEventCalls} = setup('') + + await userEvent.type(element, '{shift}a{/shift}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + focus + keydown: Shift (16) {shift} + keydown: a (97) {shift} + keypress: a (97) {shift} + input: "{CURSOR}" -> "a" + keyup: a (97) {shift} + keyup: Shift (16) + `) +}) + +test('a{enter}', async () => { + const {element, getEventCalls} = setup('') + + await userEvent.type(element, 'a{enter}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + focus + keydown: a (97) + keypress: a (97) + input: "{CURSOR}" -> "a" + keyup: a (97) + keydown: Enter (13) + keypress: Enter (13) + keyup: Enter (13) + `) +}) + +test('{enter} with preventDefault keydown', async () => { + const {element, getEventCalls} = setup('', { + eventHandlers: { + keyDown: e => e.preventDefault(), + }, + }) + + await userEvent.type(element, '{enter}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + focus + keydown: Enter (13) + keyup: Enter (13) + `) +}) + +test('{enter} on a button', async () => { + const {element, getEventCalls} = setup('`) const input = element.children[0] const button = element.children[1] @@ -176,14 +176,14 @@ test('does not lose focus when click updates focus', () => { expect(input).not.toHaveFocus() - userEvent.click(button) + await userEvent.click(button) expect(input).toHaveFocus() - userEvent.click(button) + await userEvent.click(button) expect(input).toHaveFocus() }) -test('gives focus to the form control when clicking the label', () => { +test('gives focus to the form control when clicking the label', async () => { const {element} = setup(`
@@ -193,11 +193,11 @@ test('gives focus to the form control when clicking the label', () => { const label = element.children[0] const input = element.children[1] - userEvent.click(label) + await userEvent.click(label) expect(input).toHaveFocus() }) -test('gives focus to the form control when clicking within a label', () => { +test('gives focus to the form control when clicking within a label', async () => { const {element} = setup(`
@@ -208,11 +208,11 @@ test('gives focus to the form control when clicking within a label', () => { const span = label.firstChild const input = element.children[1] - userEvent.click(span) + await userEvent.click(span) expect(input).toHaveFocus() }) -test('clicking a label checks the checkbox', () => { +test('clicking a label checks the checkbox', async () => { const {element} = setup(`
@@ -222,12 +222,12 @@ test('clicking a label checks the checkbox', () => { const label = element.children[0] const input = element.children[1] - userEvent.click(label) + await userEvent.click(label) expect(input).toHaveFocus() expect(input).toBeChecked() }) -test('clicking a label checks the radio', () => { +test('clicking a label checks the radio', async () => { const {element} = setup(`
@@ -237,50 +237,50 @@ test('clicking a label checks the radio', () => { const label = element.children[0] const input = element.children[1] - userEvent.click(label) + await userEvent.click(label) expect(input).toHaveFocus() expect(input).toBeChecked() }) -test('submits a form when clicking on a `) - userEvent.click(element.children[0]) + await userEvent.click(element.children[0]) expect(getEventCalls()).toContain('submit') }) -test('does not submit a form when clicking on a `) - userEvent.click(element.children[0]) + await userEvent.click(element.children[0]) expect(getEventCalls()).not.toContain('submit') }) -test('does not fire blur on current element if is the same as previous', () => { +test('does not fire blur on current element if is the same as previous', async () => { const {element, getEventCalls, clearEventCalls} = setup('`) - userEvent.tab() + await userEvent.tab() expect(document.body).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(document.body).toHaveFocus() }) -test('should respect radio groups', () => { +test('should respect radio groups', async () => { setup(`
{ '[data-testid="element"]', ) - userEvent.tab() + await userEvent.tab() expect(firstLeft).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(secondRight).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(firstRight).toHaveFocus() }) diff --git a/src/user-event/__tests__/toggleselectoptions.js b/src/user-event/__tests__/toggle-selectoptions.js similarity index 81% rename from src/user-event/__tests__/toggleselectoptions.js rename to src/user-event/__tests__/toggle-selectoptions.js index 0afc8849..792e05af 100644 --- a/src/user-event/__tests__/toggleselectoptions.js +++ b/src/user-event/__tests__/toggle-selectoptions.js @@ -1,10 +1,10 @@ import * as userEvent from '..' import {addListeners, setupSelect, setup} from './helpers/utils' -test('should fire the correct events for multiple select', () => { +test('should fire the correct events for multiple select', async () => { const {form, select, getEventCalls} = setupSelect({multiple: true}) - userEvent.toggleSelectOptions(select, '1') + await userEvent.toggleSelectOptions(select, '1') expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: select[name="select"][value=["1"]] @@ -26,7 +26,7 @@ test('should fire the correct events for multiple select', () => { expect(form).toHaveFormValues({select: ['1']}) }) -test('should fire the correct events for multiple select when focus is in other element', () => { +test('should fire the correct events for multiple select when focus is in other element', async () => { const {select} = setupSelect({multiple: true}) const button = document.createElement('button') document.body.append(button) @@ -36,7 +36,7 @@ test('should fire the correct events for multiple select when focus is in other button.focus() - userEvent.toggleSelectOptions(select, '1') + await userEvent.toggleSelectOptions(select, '1') expect(getButtonEventCalls()).toMatchInlineSnapshot(` Events fired on: button @@ -64,7 +64,7 @@ test('should fire the correct events for multiple select when focus is in other `) }) -test('toggle options as expected', () => { +test('toggle options as expected', async () => { const {element} = setup(`
`) - expect(() => { - userEvent.toggleSelectOptions(element) - }).toThrowErrorMatchingInlineSnapshot( + const error = await userEvent.toggleSelectOptions(element).catch(e => e) + expect(error.message).toMatchInlineSnapshot( `Unable to toggleSelectOptions - please provide a select element with multiple=true`, ) }) diff --git a/src/user-event/__tests__/type.js b/src/user-event/__tests__/type.js index 78bd0e39..3ca8043c 100644 --- a/src/user-event/__tests__/type.js +++ b/src/user-event/__tests__/type.js @@ -1,6 +1,6 @@ import * as userEvent from '..' import {setup, addListeners} from './helpers/utils' -import './helpers/customElement' +import './helpers/custom-element' test('types text in input', async () => { const {element, getEventCalls} = setup('') diff --git a/src/user-event/__tests__/upload.js b/src/user-event/__tests__/upload.js index 4e5da8e9..b130e6e7 100644 --- a/src/user-event/__tests__/upload.js +++ b/src/user-event/__tests__/upload.js @@ -1,11 +1,11 @@ import * as userEvent from '..' import {setup, addListeners} from './helpers/utils' -test('should fire the correct events for input', () => { +test('should fire the correct events for input', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) const {element, getEventCalls} = setup('') - userEvent.upload(element, file) + await userEvent.upload(element, file) expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -20,7 +20,7 @@ test('should fire the correct events for input', () => { `) }) -test('should fire the correct events with label', () => { +test('should fire the correct events with label', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) const container = document.createElement('div') @@ -34,7 +34,7 @@ test('should fire the correct events with label', () => { const {getEventCalls: getLabelEventCalls} = addListeners(label) const {getEventCalls: getInputEventCalls} = addListeners(input) - userEvent.upload(label, file) + await userEvent.upload(label, file) expect(getLabelEventCalls()).toMatchInlineSnapshot(` Events fired on: label[for="element"] @@ -54,25 +54,25 @@ test('should fire the correct events with label', () => { `) }) -test('should upload the file', () => { +test('should upload the file', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) const {element} = setup('') - userEvent.upload(element, file) + await userEvent.upload(element, file) expect(element.files[0]).toStrictEqual(file) expect(element.files.item(0)).toStrictEqual(file) expect(element.files).toHaveLength(1) }) -test('should upload multiple files', () => { +test('should upload multiple files', async () => { const files = [ new File(['hello'], 'hello.png', {type: 'image/png'}), new File(['there'], 'there.png', {type: 'image/png'}), ] const {element} = setup('') - userEvent.upload(element, files) + await userEvent.upload(element, files) expect(element.files[0]).toStrictEqual(files[0]) expect(element.files.item(0)).toStrictEqual(files[0]) @@ -81,11 +81,11 @@ test('should upload multiple files', () => { expect(element.files).toHaveLength(2) }) -test('should not upload when is disabled', () => { +test('should not upload when is disabled', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) const {element} = setup('') - userEvent.upload(element, file) + await userEvent.upload(element, file) expect(element.files[0]).toBeUndefined() expect(element.files.item(0)).toBeNull() diff --git a/src/user-event/index.js b/src/user-event/index.js index 890d1452..a67f18b9 100644 --- a/src/user-event/index.js +++ b/src/user-event/index.js @@ -1,4 +1,5 @@ -import {fireEvent} from '../events' +import {wrapAsync} from '../wrap-async' +import {fireEvent} from './tick-fire-event' import {type} from './type' import {tick} from './tick' @@ -72,12 +73,12 @@ function getMouseEventOptions(event, init, clickCount = 0) { } } -function clickLabel(label, init) { - fireEvent.mouseOver(label, getMouseEventOptions('mouseover', init)) - fireEvent.mouseMove(label, getMouseEventOptions('mousemove', init)) - fireEvent.mouseDown(label, getMouseEventOptions('mousedown', init)) - fireEvent.mouseUp(label, getMouseEventOptions('mouseup', init)) - fireEvent.click(label, getMouseEventOptions('click', init)) +async function clickLabel(label, init) { + await fireEvent.mouseOver(label, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(label, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(label, getMouseEventOptions('mousedown', init)) + await fireEvent.mouseUp(label, getMouseEventOptions('mouseup', init)) + await fireEvent.click(label, getMouseEventOptions('click', init)) // clicking the label will trigger a click of the label.control // however, it will not focus the label.control so we have to do it @@ -85,21 +86,21 @@ function clickLabel(label, init) { if (label.control) label.control.focus() } -function clickBooleanElement(element, init) { +async function clickBooleanElement(element, init) { if (element.disabled) return - fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) - fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) - fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init)) - fireEvent.focus(element) - fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) - fireEvent.click(element, getMouseEventOptions('click', init)) + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(element) + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) + await fireEvent.click(element, getMouseEventOptions('click', init)) } -function clickElement(element, previousElement, init) { - fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) - fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) - const continueDefaultHandling = fireEvent.mouseDown( +async function clickElement(element, previousElement, init) { + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + const continueDefaultHandling = await fireEvent.mouseDown( element, getMouseEventOptions('mousedown', init), ) @@ -108,16 +109,16 @@ function clickElement(element, previousElement, init) { if (previousElement) previousElement.blur() if (shouldFocus) element.focus() } - fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) - fireEvent.click(element, getMouseEventOptions('click', init, 1)) + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) + await fireEvent.click(element, getMouseEventOptions('click', init, 1)) const parentLabel = element.closest('label') if (parentLabel?.control) parentLabel?.control.focus?.() } -function dblClickElement(element, previousElement, init) { - fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) - fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) - const continueDefaultHandling = fireEvent.mouseDown( +async function dblClickElement(element, previousElement, init) { + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + const continueDefaultHandling = await fireEvent.mouseDown( element, getMouseEventOptions('mousedown', init), ) @@ -126,83 +127,86 @@ function dblClickElement(element, previousElement, init) { if (previousElement) previousElement.blur() if (shouldFocus) element.focus() } - fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) - fireEvent.click(element, getMouseEventOptions('click', init, 1)) + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) + await fireEvent.click(element, getMouseEventOptions('click', init, 1)) const parentLabel = element.closest('label') if (parentLabel?.control) parentLabel?.control.focus?.() - fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init, 1)) - fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init, 1)) - fireEvent.click(element, getMouseEventOptions('click', init, 2)) - fireEvent.dblClick(element, getMouseEventOptions('dblclick', init, 2)) + await fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init, 1)) + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init, 1)) + await fireEvent.click(element, getMouseEventOptions('click', init, 2)) + await fireEvent.dblClick(element, getMouseEventOptions('dblclick', init, 2)) } -function dblClickCheckbox(checkbox, init) { - fireEvent.mouseOver(checkbox, getMouseEventOptions('mouseover', init)) - fireEvent.mouseMove(checkbox, getMouseEventOptions('mousemove', init)) - fireEvent.mouseDown(checkbox, getMouseEventOptions('mousedown', init)) - fireEvent.focus(checkbox) - fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init)) - fireEvent.click(checkbox, getMouseEventOptions('click', init, 1)) - fireEvent.mouseDown(checkbox, getMouseEventOptions('mousedown', init, 1)) - fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init, 1)) - fireEvent.click(checkbox, getMouseEventOptions('click', init, 2)) +async function dblClickCheckbox(checkbox, init) { + await fireEvent.mouseOver(checkbox, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(checkbox, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(checkbox, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(checkbox) + await fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init)) + await fireEvent.click(checkbox, getMouseEventOptions('click', init, 1)) + await fireEvent.mouseDown( + checkbox, + getMouseEventOptions('mousedown', init, 1), + ) + await fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init, 1)) + await fireEvent.click(checkbox, getMouseEventOptions('click', init, 2)) } -function selectOption(select, option, init) { - fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) - fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) - fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) - fireEvent.focus(option) - fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) - fireEvent.click(option, getMouseEventOptions('click', init, 1)) +async function selectOption(select, option, init) { + await fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(option) + await fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) + await fireEvent.click(option, getMouseEventOptions('click', init, 1)) option.selected = true - fireEvent.change(select) + await fireEvent.change(select) } -function toggleSelectOption(select, option, init) { - fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) - fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) - fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) - fireEvent.focus(option) - fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) - fireEvent.click(option, getMouseEventOptions('click', init, 1)) +async function toggleSelectOption(select, option, init) { + await fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(option) + await fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) + await fireEvent.click(option, getMouseEventOptions('click', init, 1)) option.selected = !option.selected - fireEvent.change(select) + await fireEvent.change(select) } const Keys = { Backspace: {keyCode: 8, code: 'Backspace', key: 'Backspace'}, } -function backspace(element) { +async function backspace(element) { const keyboardEventOptions = { key: Keys.Backspace.key, keyCode: Keys.Backspace.keyCode, which: Keys.Backspace.keyCode, } - fireEvent.keyDown(element, keyboardEventOptions) - fireEvent.keyUp(element, keyboardEventOptions) + await fireEvent.keyDown(element, keyboardEventOptions) + await fireEvent.keyUp(element, keyboardEventOptions) if (!element.readOnly) { - fireEvent.input(element, { + await fireEvent.input(element, { inputType: 'deleteContentBackward', }) - // We need to call `fireEvent.change` _before_ we change `element.value` - // because `fireEvent.change` will use the element's native value setter + // We need to call `await fireEvent.change` _before_ we change `element.value` + // because `await fireEvent.change` will use the element's native value setter // (meaning it will avoid prototype overrides implemented by React). If we // call `input.value = ""` first, React will swallow the change event (this - // is checked in the tests). `fireEvent.change` will only call the native + // is checked in the tests). `await fireEvent.change` will only call the native // value setter method if the event options include `{ target: { value }}` // (https://github.com/testing-library/dom-testing-library/blob/8846eaf20972f8e41ed11f278948ac38a692c3f1/src/events.js#L29-L32). // // Also, we still must call `element.value = ""` after calling - // `fireEvent.change` because `fireEvent.change` will _only_ call the native + // `await fireEvent.change` because `await fireEvent.change` will _only_ call the native // `value` setter and not the prototype override defined by React, causing // React's internal represetation of this state to get out of sync with the // value set on `input.value`; calling `element.value` after will also call @@ -210,13 +214,13 @@ function backspace(element) { // // Comment either of these out or re-order them and see what parts of the // tests fail for more context. - fireEvent.change(element, {target: {value: ''}}) + await fireEvent.change(element, {target: {value: ''}}) element.value = '' } } -function selectAll(element) { - dblClick(element) // simulate events (will not actually select) +async function selectAll(element) { + await dblClick(element) // simulate events (will not actually select) const elementType = element.type // type is a readonly property on textarea, so check if element is an input before trying to modify it if (isInputElement(element)) { @@ -242,14 +246,14 @@ function getPreviouslyFocusedElement(element) { return wasAnotherElementFocused ? focusedElement : null } -function click(element, init) { +async function click(element, init) { const previouslyFocusedElement = getPreviouslyFocusedElement(element) if (previouslyFocusedElement) { - fireEvent.mouseMove( + await fireEvent.mouseMove( previouslyFocusedElement, getMouseEventOptions('mousemove', init), ) - fireEvent.mouseLeave( + await fireEvent.mouseLeave( previouslyFocusedElement, getMouseEventOptions('mouseleave', init), ) @@ -257,27 +261,28 @@ function click(element, init) { switch (element.tagName) { case 'LABEL': - clickLabel(element, init) + await clickLabel(element, init) break case 'INPUT': if (element.type === 'checkbox' || element.type === 'radio') { - clickBooleanElement(element, init) + await clickBooleanElement(element, init) break } // eslint-disable-next-line no-fallthrough default: - clickElement(element, previouslyFocusedElement, init) + await clickElement(element, previouslyFocusedElement, init) } } +click = wrapAsync(click) -function dblClick(element, init) { +async function dblClick(element, init) { const previouslyFocusedElement = getPreviouslyFocusedElement(element) if (previouslyFocusedElement) { - fireEvent.mouseMove( + await fireEvent.mouseMove( previouslyFocusedElement, getMouseEventOptions('mousemove', init), ) - fireEvent.mouseLeave( + await fireEvent.mouseLeave( previouslyFocusedElement, getMouseEventOptions('mouseleave', init), ) @@ -286,29 +291,30 @@ function dblClick(element, init) { switch (element.tagName) { case 'INPUT': if (element.type === 'checkbox') { - dblClickCheckbox(element, previouslyFocusedElement, init) + await dblClickCheckbox(element, previouslyFocusedElement, init) break } // eslint-disable-next-line no-fallthrough default: - dblClickElement(element, previouslyFocusedElement, init) + await dblClickElement(element, previouslyFocusedElement, init) } } +dblClick = wrapAsync(dblClick) -function selectOptions(element, values, init) { +async function selectOptions(element, values, init) { const previouslyFocusedElement = getPreviouslyFocusedElement(element) if (previouslyFocusedElement) { - fireEvent.mouseMove( + await fireEvent.mouseMove( previouslyFocusedElement, getMouseEventOptions('mousemove', init), ) - fireEvent.mouseLeave( + await fireEvent.mouseLeave( previouslyFocusedElement, getMouseEventOptions('mouseleave', init), ) } - clickElement(element, previouslyFocusedElement, init) + await clickElement(element, previouslyFocusedElement, init) const valArray = Array.isArray(values) ? values : [values] const selectedOptions = Array.from(element.querySelectorAll('option')).filter( @@ -317,14 +323,17 @@ function selectOptions(element, values, init) { if (selectedOptions.length > 0) { if (element.multiple) { - selectedOptions.forEach(option => selectOption(element, option)) + for (const option of selectedOptions) { + await selectOption(element, option) + } } else { - selectOption(element, selectedOptions[0]) + await selectOption(element, selectedOptions[0]) } } } +selectOptions = wrapAsync(selectOptions) -function toggleSelectOptions(element, values, init) { +async function toggleSelectOptions(element, values, init) { if (!element || element.tagName !== 'SELECT' || !element.multiple) { throw new Error( `Unable to toggleSelectOptions - please provide a select element with multiple=true`, @@ -333,17 +342,17 @@ function toggleSelectOptions(element, values, init) { const previouslyFocusedElement = getPreviouslyFocusedElement(element) if (previouslyFocusedElement) { - fireEvent.mouseMove( + await fireEvent.mouseMove( previouslyFocusedElement, getMouseEventOptions('mousemove', init), ) - fireEvent.mouseLeave( + await fireEvent.mouseLeave( previouslyFocusedElement, getMouseEventOptions('mouseleave', init), ) } - clickElement(element, previouslyFocusedElement, init) + await clickElement(element, previouslyFocusedElement, init) const valArray = Array.isArray(values) ? values : [values] const selectedOptions = Array.from(element.querySelectorAll('option')).filter( @@ -351,32 +360,36 @@ function toggleSelectOptions(element, values, init) { ) if (selectedOptions.length > 0) { - selectedOptions.forEach(option => toggleSelectOption(element, option, init)) + for (const option of selectedOptions) { + await toggleSelectOption(element, option, init) + } } } +toggleSelectOptions = wrapAsync(toggleSelectOptions) -function clear(element) { +async function clear(element) { if (element.disabled) return - selectAll(element) - backspace(element) + await selectAll(element) + await backspace(element) } +clear = wrapAsync(clear) -function upload(element, fileOrFiles, {clickInit, changeInit} = {}) { +async function upload(element, fileOrFiles, {clickInit, changeInit} = {}) { if (element.disabled) return const focusedElement = element.ownerDocument.activeElement let files if (element.tagName === 'LABEL') { - clickLabel(element) + await clickLabel(element) files = element.control.multiple ? fileOrFiles : [fileOrFiles] } else { files = element.multiple ? fileOrFiles : [fileOrFiles] - clickElement(element, focusedElement, clickInit) + await clickElement(element, focusedElement, clickInit) } - fireEvent.change(element, { + await fireEvent.change(element, { target: { files: { length: files.length, @@ -387,8 +400,9 @@ function upload(element, fileOrFiles, {clickInit, changeInit} = {}) { ...changeInit, }) } +upload = wrapAsync(upload) -function tab({shift = false, focusTrap = document} = {}) { +async function tab({shift = false, focusTrap = document} = {}) { const focusableElements = focusTrap.querySelectorAll( 'input, button, select, textarea, a[href], [tabindex]', ) @@ -450,25 +464,31 @@ function tab({shift = false, focusTrap = document} = {}) { } else { next.focus() } + // everything in user-event must be actually async, but since we're not + // calling fireEvent in here, we'll add this tick here... + await tick() } +tab = wrapAsync(tab) async function hover(element, init) { await tick() - fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) await tick() - fireEvent.mouseEnter(element, getMouseEventOptions('mouseenter', init)) + await fireEvent.mouseEnter(element, getMouseEventOptions('mouseenter', init)) await tick() - fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) } +hover = wrapAsync(hover) async function unhover(element, init) { await tick() - fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) await tick() - fireEvent.mouseOut(element, getMouseEventOptions('mouseout', init)) + await fireEvent.mouseOut(element, getMouseEventOptions('mouseout', init)) await tick() - fireEvent.mouseLeave(element, getMouseEventOptions('mouseleave', init)) + await fireEvent.mouseLeave(element, getMouseEventOptions('mouseleave', init)) } +unhover = wrapAsync(unhover) export { click, diff --git a/src/user-event/tick-fire-event.js b/src/user-event/tick-fire-event.js new file mode 100644 index 00000000..457cd363 --- /dev/null +++ b/src/user-event/tick-fire-event.js @@ -0,0 +1,18 @@ +import {fireEvent as baseFireEvent} from '../events' +import {tick} from './tick' + +async function fireEvent(...args) { + await tick() + return baseFireEvent(...args) +} + +Object.keys(baseFireEvent).forEach(key => { + async function asyncFireEventWrapper(...args) { + await tick() + return baseFireEvent[key](...args) + } + Object.defineProperty(asyncFireEventWrapper, 'name', {value: key}) + fireEvent[key] = asyncFireEventWrapper +}) + +export {fireEvent} diff --git a/src/user-event/tick.js b/src/user-event/tick.js index 126e39bc..9ec1bea4 100644 --- a/src/user-event/tick.js +++ b/src/user-event/tick.js @@ -43,12 +43,6 @@ try { } } -function tick() { - return { - then(resolve) { - enqueueTask(resolve) - }, - } -} +const tick = () => new Promise(resolve => enqueueTask(resolve)) export {tick} diff --git a/src/user-event/type.js b/src/user-event/type.js index 2c73efec..d4530636 100644 --- a/src/user-event/type.js +++ b/src/user-event/type.js @@ -1,20 +1,10 @@ -import {fireEvent} from '../events' -import {getConfig} from '../config' -import {tick} from './tick' +import {wrapAsync} from '../wrap-async' +import {fireEvent} from './tick-fire-event' function wait(time) { return new Promise(resolve => setTimeout(() => resolve(), time)) } -// this needs to be wrapped in the asyncWrapper for React's act and angular's change detection -async function type(...args) { - let result - await getConfig().asyncWrapper(async () => { - result = await typeImpl(...args) - }) - return result -} - const getActiveElement = document => { const activeElement = document.activeElement if (activeElement.shadowRoot) { @@ -25,7 +15,7 @@ const getActiveElement = document => { } // eslint-disable-next-line complexity -async function typeImpl( +async function type( element, text, {allAtOnce = false, delay, initialSelectionStart, initialSelectionEnd} = {}, @@ -40,9 +30,9 @@ async function typeImpl( const setSelectionRange = ({newValue, newSelectionStart}) => { // if we *can* change the selection start, then we will if the new value // is the same as the current value (so it wasn't programatically changed - // when the fireEvent.input was triggered). + // when the await fireEvent.input was triggered). // The reason we have to do this at all is because it actually *is* - // programmatically changed by fireEvent.input, so we have to simulate the + // programmatically changed by await fireEvent.input, so we have to simulate the // browser's default behavior if ( currentElement().selectionStart !== null && @@ -73,7 +63,7 @@ async function typeImpl( if (allAtOnce) { if (!element.readOnly) { const {newValue, newSelectionStart} = calculateNewValue(text) - fireEvent.input(element, { + await fireEvent.input(element, { target: {value: newValue}, }) setSelectionRange({newValue, newSelectionStart}) @@ -108,17 +98,18 @@ async function typeImpl( const key = 'Enter' const keyCode = 13 - const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) + const keyDownDefaultNotPrevented = await fireEvent.keyDown( + currentElement(), + { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }, + ) if (keyDownDefaultNotPrevented) { - await tick() - - fireEvent.keyPress(currentElement(), { + await fireEvent.keyPress(currentElement(), { key, keyCode, charCode: keyCode, @@ -127,16 +118,14 @@ async function typeImpl( } if (currentElement().tagName === 'BUTTON') { - await tick() - fireEvent.click(currentElement(), { + await fireEvent.click(currentElement(), { ...eventOverrides, }) } if (currentElement().tagName === 'TEXTAREA') { - await tick() const {newValue, newSelectionStart} = calculateNewValue('\n') - fireEvent.input(currentElement(), { + await fireEvent.input(currentElement(), { target: {value: newValue}, inputType: 'insertLineBreak', ...eventOverrides, @@ -144,9 +133,7 @@ async function typeImpl( setSelectionRange({newValue, newSelectionStart}) } - await tick() - - fireEvent.keyUp(currentElement(), { + await fireEvent.keyUp(currentElement(), { key, keyCode, which: keyCode, @@ -157,18 +144,16 @@ async function typeImpl( const key = 'Escape' const keyCode = 27 - fireEvent.keyDown(currentElement(), { + await fireEvent.keyDown(currentElement(), { key, keyCode, which: keyCode, ...eventOverrides, }) - await tick() - // NOTE: Browsers do not fire a keypress on meta key presses - fireEvent.keyUp(currentElement(), { + await fireEvent.keyUp(currentElement(), { key, keyCode, which: keyCode, @@ -179,7 +164,7 @@ async function typeImpl( const key = 'Backspace' const keyCode = 8 - const keyPressDefaultNotPrevented = fireEvent.keyDown( + const keyPressDefaultNotPrevented = await fireEvent.keyDown( currentElement(), { key, @@ -199,9 +184,7 @@ async function typeImpl( }) } - await tick() - - fireEvent.keyUp(currentElement(), { + await fireEvent.keyUp(currentElement(), { key, keyCode, which: keyCode, @@ -243,9 +226,7 @@ async function typeImpl( }) { const prevValue = currentValue() if (!currentElement().readOnly && newValue !== prevValue) { - await tick() - - fireEvent.input(currentElement(), { + await fireEvent.input(currentElement(), { target: {value: newValue}, ...eventOverrides, }) @@ -342,22 +323,26 @@ async function typeImpl( const keyCode = char.charCodeAt(0) let nextPrevWasMinus - const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (keyDownDefaultNotPrevented) { - await tick() - - const keyPressDefaultNotPrevented = fireEvent.keyPress(currentElement(), { + const keyDownDefaultNotPrevented = await fireEvent.keyDown( + currentElement(), + { key, keyCode, - charCode: keyCode, + which: keyCode, ...eventOverrides, - }) + }, + ) + + if (keyDownDefaultNotPrevented) { + const keyPressDefaultNotPrevented = await fireEvent.keyPress( + currentElement(), + { + key, + keyCode, + charCode: keyCode, + ...eventOverrides, + }, + ) if (keyPressDefaultNotPrevented) { const newEntry = prevWasMinus ? `-${char}` : char @@ -387,9 +372,7 @@ async function typeImpl( } } - await tick() - - fireEvent.keyUp(currentElement(), { + await fireEvent.keyUp(currentElement(), { key, keyCode, which: keyCode, @@ -401,10 +384,10 @@ async function typeImpl( function modifier({name, key, keyCode, modifierProperty}) { return { - [`{${name}}`]: ({eventOverrides}) => { + [`{${name}}`]: async ({eventOverrides}) => { const newEventOverrides = {[modifierProperty]: true} - fireEvent.keyDown(currentElement(), { + await fireEvent.keyDown(currentElement(), { key, keyCode, which: keyCode, @@ -414,10 +397,10 @@ async function typeImpl( return {eventOverrides: newEventOverrides} }, - [`{/${name}}`]: ({eventOverrides}) => { + [`{/${name}}`]: async ({eventOverrides}) => { const newEventOverrides = {[modifierProperty]: false} - fireEvent.keyUp(currentElement(), { + await fireEvent.keyUp(currentElement(), { key, keyCode, which: keyCode, @@ -431,11 +414,12 @@ async function typeImpl( } } +type = wrapAsync(type) + export {type} /* eslint - no-await-in-loop: "off", no-loop-func: "off", max-lines-per-function: "off", */ diff --git a/src/wrap-async.js b/src/wrap-async.js new file mode 100644 index 00000000..66c49a30 --- /dev/null +++ b/src/wrap-async.js @@ -0,0 +1,16 @@ +import {getConfig} from './config' + +function wrapAsync(fn) { + async function wrapper(...args) { + let result + await getConfig().asyncWrapper(async () => { + result = await fn(...args) + }) + return result + } + // give it a helpful name for debugging + Object.defineProperty(wrapper, 'name', {value: `${fn.name}Wrapper`}) + return wrapper +} + +export {wrapAsync} From 277d0c8c8d20ca9c293b6c88903010303da18f79 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 10 Jun 2020 23:37:01 -0600 Subject: [PATCH 04/31] test: add all event types as listeners --- src/user-event/__tests__/clear.js | 2 ++ src/user-event/__tests__/helpers/utils.js | 37 +++++----------------- src/user-event/__tests__/type-modifiers.js | 27 ++++++++++++++++ src/user-event/__tests__/type.js | 37 ++++++++++++++++++++++ src/user-event/__tests__/unhover.js | 1 + 5 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src/user-event/__tests__/clear.js b/src/user-event/__tests__/clear.js index d16afdd8..3160b3ce 100644 --- a/src/user-event/__tests__/clear.js +++ b/src/user-event/__tests__/clear.js @@ -18,6 +18,7 @@ test('clears text', async () => { mouseup: Left (0) click: Left (0) dblclick: Left (0) + select keydown: Backspace (8) keyup: Backspace (8) input: "{SELECTION}hello{/SELECTION}" -> "hello" @@ -51,6 +52,7 @@ test('does not clear text on readonly inputs', async () => { mouseup: Left (0) click: Left (0) dblclick: Left (0) + select keydown: Backspace (8) keyup: Backspace (8) `) diff --git a/src/user-event/__tests__/helpers/utils.js b/src/user-event/__tests__/helpers/utils.js index 3ebdbb60..45cf2a03 100644 --- a/src/user-event/__tests__/helpers/utils.js +++ b/src/user-event/__tests__/helpers/utils.js @@ -1,5 +1,6 @@ +import {eventMap} from '../../../event-map' // this is pretty helpful: -// https://jsbin.com/nimelileyo/edit?js,output +// https://codesandbox.io/s/quizzical-worker-eo909 // all of the stuff below is complex magic that makes the simpler tests work // sorrynotsorry... @@ -105,33 +106,11 @@ function getElementDisplayName(element) { function addListeners(element, {eventHandlers = {}} = {}) { const generalListener = jest.fn().mockName('eventListener') - const listeners = [ - 'submit', - 'keydown', - 'keyup', - 'keypress', - 'input', - 'change', - 'blur', - 'focus', - 'focusin', - 'focusout', - 'click', - 'dblclick', - 'mouseover', - 'mousemove', - 'mouseenter', - 'mouseleave', - 'mouseup', - 'mousedown', - ] + const listeners = Object.keys(eventMap) for (const name of listeners) { - addEventListener(element, name, (...args) => { - const [, handler] = - Object.entries(eventHandlers).find( - ([key]) => key.toLowerCase() === name, - ) ?? [] + addEventListener(element, name.toLowerCase(), (...args) => { + const handler = eventHandlers[name] if (handler) { generalListener(...args) return handler(...args) @@ -146,7 +125,7 @@ function addListeners(element, {eventHandlers = {}} = {}) { function getEventCalls() { const eventCalls = generalListener.mock.calls .map(([event]) => { - const window = event.target.ownerDocument.defaultView + const window = event.target?.ownerDocument.defaultView const modifiers = ['altKey', 'shiftKey', 'metaKey', 'ctrlKey'] .filter(key => event[key]) .map(k => `{${k.replace('Key', '')}}`) @@ -161,9 +140,9 @@ function addListeners(element, {eventHandlers = {}} = {}) { log = getCheckboxOrRadioClickedLine(event) } else if (event.type === 'input' && event.hasOwnProperty('testData')) { log = getInputLine(element, event) - } else if (event instanceof window.KeyboardEvent) { + } else if (window && event instanceof window.KeyboardEvent) { log = getKeyboardEventLine(event) - } else if (event instanceof window.MouseEvent) { + } else if (window && event instanceof window.MouseEvent) { log = getMouseEventLine(event) } diff --git a/src/user-event/__tests__/type-modifiers.js b/src/user-event/__tests__/type-modifiers.js index db428274..9d358055 100644 --- a/src/user-event/__tests__/type-modifiers.js +++ b/src/user-event/__tests__/type-modifiers.js @@ -24,6 +24,7 @@ test('{esc} triggers typing the escape character', async () => { Events fired on: input[value=""] focus + select keydown: Escape (27) keyup: Escape (27) `) @@ -36,12 +37,15 @@ test('a{backspace}', async () => { Events fired on: input[value=""] focus + select keydown: a (97) keypress: a (97) input: "{CURSOR}" -> "a" + select keyup: a (97) keydown: Backspace (8) input: "a{CURSOR}" -> "" + select keyup: Backspace (8) `) }) @@ -53,11 +57,13 @@ test('{backspace}a', async () => { Events fired on: input[value="a"] focus + select keydown: Backspace (8) keyup: Backspace (8) keydown: a (97) keypress: a (97) input: "{CURSOR}" -> "a" + select keyup: a (97) `) }) @@ -71,9 +77,11 @@ test('{backspace} triggers typing the backspace character and deletes the charac expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value="o"] + select focus keydown: Backspace (8) input: "y{CURSOR}o" -> "o" + select keyup: Backspace (8) `) }) @@ -87,6 +95,7 @@ test('{backspace} on a readOnly input', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value="yo"] + select focus keydown: Backspace (8) keyup: Backspace (8) @@ -104,6 +113,7 @@ test('{backspace} does not fire input if keydown prevents default', async () => expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value="yo"] + select focus keydown: Backspace (8) keyup: Backspace (8) @@ -119,9 +129,11 @@ test('{backspace} deletes the selected range', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value="Here"] + select focus keydown: Backspace (8) input: "H{SELECTION}i th{/SELECTION}ere" -> "Here" + select keyup: Backspace (8) `) }) @@ -143,10 +155,12 @@ test('{alt}a{/alt}', async () => { Events fired on: input[value="a"] focus + select keydown: Alt (18) {alt} keydown: a (97) {alt} keypress: a (97) {alt} input: "{CURSOR}" -> "a" + select keyup: a (97) {alt} keyup: Alt (18) `) @@ -161,10 +175,12 @@ test('{meta}a{/meta}', async () => { Events fired on: input[value="a"] focus + select keydown: Meta (93) {meta} keydown: a (97) {meta} keypress: a (97) {meta} input: "{CURSOR}" -> "a" + select keyup: a (97) {meta} keyup: Meta (93) `) @@ -179,10 +195,12 @@ test('{ctrl}a{/ctrl}', async () => { Events fired on: input[value="a"] focus + select keydown: Control (17) {ctrl} keydown: a (97) {ctrl} keypress: a (97) {ctrl} input: "{CURSOR}" -> "a" + select keyup: a (97) {ctrl} keyup: Control (17) `) @@ -197,10 +215,12 @@ test('{shift}a{/shift}', async () => { Events fired on: input[value="a"] focus + select keydown: Shift (16) {shift} keydown: a (97) {shift} keypress: a (97) {shift} input: "{CURSOR}" -> "a" + select keyup: a (97) {shift} keyup: Shift (16) `) @@ -215,9 +235,11 @@ test('a{enter}', async () => { Events fired on: input[value="a"] focus + select keydown: a (97) keypress: a (97) input: "{CURSOR}" -> "a" + select keyup: a (97) keydown: Enter (13) keypress: Enter (13) @@ -238,6 +260,7 @@ test('{enter} with preventDefault keydown', async () => { Events fired on: input[value=""] focus + select keydown: Enter (13) keyup: Enter (13) `) @@ -268,10 +291,12 @@ test('{enter} on a textarea', async () => { Events fired on: textarea[value="\\n"] focus + select keydown: Enter (13) keypress: Enter (13) input: "{CURSOR}" -> " " + select keyup: Enter (13) `) }) @@ -303,12 +328,14 @@ test('{meta}{alt}{ctrl}a{/ctrl}{/alt}{/meta}', async () => { Events fired on: input[value="a"] focus + select keydown: Meta (93) {meta} keydown: Alt (18) {alt}{meta} keydown: Control (17) {alt}{meta}{ctrl} keydown: a (97) {alt}{meta}{ctrl} keypress: a (97) {alt}{meta}{ctrl} input: "{CURSOR}" -> "a" + select keyup: a (97) {alt}{meta}{ctrl} keyup: Control (17) {alt}{meta} keyup: Alt (18) {meta} diff --git a/src/user-event/__tests__/type.js b/src/user-event/__tests__/type.js index 3ca8043c..7b441d50 100644 --- a/src/user-event/__tests__/type.js +++ b/src/user-event/__tests__/type.js @@ -9,17 +9,21 @@ test('types text in input', async () => { Events fired on: input[value="Sup"] focus + select keydown: S (83) keypress: S (83) input: "{CURSOR}" -> "S" + select keyup: S (83) keydown: u (117) keypress: u (117) input: "S{CURSOR}" -> "Su" + select keyup: u (117) keydown: p (112) keypress: p (112) input: "Su{CURSOR}" -> "Sup" + select keyup: p (112) `) }) @@ -31,7 +35,9 @@ test('types text in input with allAtOnce', async () => { Events fired on: input[value="Sup"] focus + select input: "{CURSOR}" -> "Sup" + select `) }) @@ -46,17 +52,21 @@ test('types text inside custom element', async () => { Events fired on: input[value="Sup"] focus + select keydown: S (83) keypress: S (83) input: "{CURSOR}" -> "S" + select keyup: S (83) keydown: u (117) keypress: u (117) input: "S{CURSOR}" -> "Su" + select keyup: u (117) keydown: p (112) keypress: p (112) input: "Su{CURSOR}" -> "Sup" + select keyup: p (112) `) }) @@ -68,17 +78,21 @@ test('types text in textarea', async () => { Events fired on: textarea[value="Sup"] focus + select keydown: S (83) keypress: S (83) input: "{CURSOR}" -> "S" + select keyup: S (83) keydown: u (117) keypress: u (117) input: "S{CURSOR}" -> "Su" + select keyup: u (117) keydown: p (112) keypress: p (112) input: "Su{CURSOR}" -> "Sup" + select keyup: p (112) `) }) @@ -90,7 +104,9 @@ test('should append text all at once', async () => { Events fired on: input[value="Sup"] focus + select input: "{CURSOR}" -> "Sup" + select `) }) @@ -104,6 +120,7 @@ test('does not fire input event when keypress calls prevent default', async () = Events fired on: input[value=""] focus + select keydown: a (97) keypress: a (97) keyup: a (97) @@ -120,6 +137,7 @@ test('does not fire keypress or input events when keydown calls prevent default' Events fired on: input[value=""] focus + select keydown: a (97) keyup: a (97) `) @@ -142,6 +160,7 @@ test('does not fire input when readonly', async () => { Events fired on: input[value=""] focus + select keydown: a (97) keypress: a (97) keyup: a (97) @@ -156,6 +175,7 @@ test('does not fire input when readonly (with allAtOnce)', async () => { Events fired on: input[value=""] focus + select `) }) @@ -199,13 +219,16 @@ test('honors maxlength', async () => { Events fired on: input[value="12"] focus + select keydown: 1 (49) keypress: 1 (49) input: "{CURSOR}" -> "1" + select keyup: 1 (49) keydown: 2 (50) keypress: 2 (50) input: "1{CURSOR}" -> "12" + select keyup: 2 (50) keydown: 3 (51) keypress: 3 (51) @@ -222,6 +245,7 @@ test('honors maxlength with existing text', async () => { Events fired on: input[value="12"] focus + select keydown: 3 (51) keypress: 3 (51) keyup: 3 (51) @@ -297,6 +321,7 @@ test('typing into a controlled input works', async () => { Events fired on: input[value="$23"] focus + select keydown: 2 (50) keypress: 2 (50) input: "{CURSOR}" -> "$2" @@ -304,6 +329,7 @@ test('typing into a controlled input works', async () => { keydown: 3 (51) keypress: 3 (51) input: "$2{CURSOR}" -> "$23" + select keyup: 3 (51) `) }) @@ -318,10 +344,12 @@ test('typing in the middle of a controlled input works', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value="$213"] + select focus keydown: 1 (49) keypress: 1 (49) input: "$2{CURSOR}3" -> "$213" + select keyup: 1 (49) `) }) @@ -346,6 +374,7 @@ test('ignored {backspace} in controlled input', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value="$234"] + select focus keydown: Backspace (8) input: "\${CURSOR}23" -> "$23" @@ -353,6 +382,7 @@ test('ignored {backspace} in controlled input', async () => { keydown: 4 (52) keypress: 4 (52) input: "$23{CURSOR}" -> "$234" + select keyup: 4 (52) `) }) @@ -366,13 +396,16 @@ test('typing in a textarea with existing text', async () => { Events fired on: textarea[value="Hello, 12"] focus + select keydown: 1 (49) keypress: 1 (49) input: "Hello, {CURSOR}" -> "Hello, 1" + select keyup: 1 (49) keydown: 2 (50) keypress: 2 (50) input: "Hello, 1{CURSOR}" -> "Hello, 12" + select keyup: 2 (50) `) expect(element).toHaveValue('Hello, 12') @@ -390,14 +423,18 @@ test('accepts an initialSelectionStart and initialSelectionEnd', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: textarea[value="12Hello, "] + select focus + select keydown: 1 (49) keypress: 1 (49) input: "{CURSOR}Hello, " -> "1Hello, " + select keyup: 1 (49) keydown: 2 (50) keypress: 2 (50) input: "1{CURSOR}Hello, " -> "12Hello, " + select keyup: 2 (50) `) expect(element).toHaveValue('12Hello, ') diff --git a/src/user-event/__tests__/unhover.js b/src/user-event/__tests__/unhover.js index 49b7620f..6306bcf7 100644 --- a/src/user-event/__tests__/unhover.js +++ b/src/user-event/__tests__/unhover.js @@ -9,6 +9,7 @@ test('unhover', async () => { Events fired on: button mousemove: Left (0) + mouseout: Left (0) mouseleave: Left (0) `) }) From faaf8dc15b06afaabea278b13a07262a67df920a Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 11 Jun 2020 00:37:22 -0600 Subject: [PATCH 05/31] feat(userEvent): add {del} and {selectall} --- src/user-event/__tests__/type-modifiers.js | 104 +++++++++++++++++++++ src/user-event/index.js | 7 +- src/user-event/type.js | 67 +++++++++++++ 3 files changed, 175 insertions(+), 3 deletions(-) diff --git a/src/user-event/__tests__/type-modifiers.js b/src/user-event/__tests__/type-modifiers.js index 9d358055..f2530878 100644 --- a/src/user-event/__tests__/type-modifiers.js +++ b/src/user-event/__tests__/type-modifiers.js @@ -342,3 +342,107 @@ test('{meta}{alt}{ctrl}a{/ctrl}{/alt}{/meta}', async () => { keyup: Meta (93) `) }) + +test('{selectall} selects all the text', async () => { + const value = 'abcdefg' + const {element, clearEventCalls, getEventCalls} = setup( + ``, + ) + element.setSelectionRange(2, 6) + + clearEventCalls() + + await userEvent.type(element, '{selectall}') + + expect(element.selectionStart).toBe(0) + expect(element.selectionEnd).toBe(value.length) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="abcdefg"] + + focus + select + `) +}) + +test('{del} at the start of the input', async () => { + const {element, getEventCalls} = setup(``) + + await userEvent.type(element, '{del}', { + initialSelectionStart: 0, + initialSelectionEnd: 0, + }) + + expect(element.selectionStart).toBe(0) + expect(element.selectionEnd).toBe(0) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="ello"] + + focus + select + keydown: Delete (46) + input: "{CURSOR}hello" -> "ello" + select + keyup: Delete (46) + `) +}) + +test('{del} at end of the input', async () => { + const {element, getEventCalls} = setup(``) + + await userEvent.type(element, '{del}') + + expect(element.selectionStart).toBe(element.value.length) + expect(element.selectionEnd).toBe(element.value.length) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="hello"] + + focus + select + keydown: Delete (46) + keyup: Delete (46) + `) +}) + +test('{del} in the middle of the input', async () => { + const {element, getEventCalls} = setup(``) + + await userEvent.type(element, '{del}', { + initialSelectionStart: 2, + initialSelectionEnd: 2, + }) + + expect(element.selectionStart).toBe(2) + expect(element.selectionEnd).toBe(2) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="helo"] + + focus + select + keydown: Delete (46) + input: "he{CURSOR}llo" -> "helo" + select + keyup: Delete (46) + `) +}) + +test('{del} with a selection range', async () => { + const {element, getEventCalls} = setup(``) + + await userEvent.type(element, '{del}', { + initialSelectionStart: 1, + initialSelectionEnd: 3, + }) + + expect(element.selectionStart).toBe(1) + expect(element.selectionEnd).toBe(1) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value="hlo"] + + focus + select + keydown: Delete (46) + input: "h{SELECTION}el{/SELECTION}lo" -> "hlo" + select + keyup: Delete (46) + `) +}) diff --git a/src/user-event/index.js b/src/user-event/index.js index a67f18b9..46e92c55 100644 --- a/src/user-event/index.js +++ b/src/user-event/index.js @@ -403,6 +403,10 @@ async function upload(element, fileOrFiles, {clickInit, changeInit} = {}) { upload = wrapAsync(upload) async function tab({shift = false, focusTrap = document} = {}) { + // everything in user-event must be actually async, but since we're not + // calling fireEvent in here, we'll add this tick here... + await tick() + const focusableElements = focusTrap.querySelectorAll( 'input, button, select, textarea, a[href], [tabindex]', ) @@ -464,9 +468,6 @@ async function tab({shift = false, focusTrap = document} = {}) { } else { next.focus() } - // everything in user-event must be actually async, but since we're not - // calling fireEvent in here, we'll add this tick here... - await tick() } tab = wrapAsync(tab) diff --git a/src/user-event/type.js b/src/user-event/type.js index d4530636..eb618798 100644 --- a/src/user-event/type.js +++ b/src/user-event/type.js @@ -1,5 +1,6 @@ import {wrapAsync} from '../wrap-async' import {fireEvent} from './tick-fire-event' +import {tick} from './tick' function wait(time) { return new Promise(resolve => setTimeout(() => resolve(), time)) @@ -160,6 +161,37 @@ async function type( ...eventOverrides, }) }, + '{del}': async ({eventOverrides}) => { + const key = 'Delete' + const keyCode = 46 + + const keyPressDefaultNotPrevented = await fireEvent.keyDown( + currentElement(), + { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }, + ) + + if (keyPressDefaultNotPrevented) { + await fireInputEventIfNeeded({ + ...calculateNewDeleteValue(), + eventOverrides: { + inputType: 'deleteContentForward', + ...eventOverrides, + }, + }) + } + + await fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) + }, '{backspace}': async ({eventOverrides}) => { const key = 'Backspace' const keyCode = 8 @@ -191,6 +223,10 @@ async function type( ...eventOverrides, }) }, + '{selectall}': async () => { + await tick() + currentElement().setSelectionRange(0, currentValue().length) + }, } const eventCallbacks = [] let remainingString = text @@ -255,6 +291,7 @@ async function type( if (selectionStart === 0) { // at the beginning of the input newValue = value + newSelectionStart = selectionStart } else if (selectionStart === value.length) { // at the end of the input newValue = value.slice(0, value.length - 1) @@ -275,6 +312,36 @@ async function type( return {newValue, newSelectionStart} } + function calculateNewDeleteValue() { + const {selectionStart, selectionEnd} = currentElement() + const value = currentValue() + let newValue + + if (selectionStart === null) { + // at the end of an input type that does not support selection ranges + // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793 + newValue = value + } else if (selectionStart === selectionEnd) { + if (selectionStart === 0) { + // at the beginning of the input + newValue = value.slice(1) + } else if (selectionStart === value.length) { + // at the end of the input + newValue = value + } else { + // in the middle of the input + newValue = + value.slice(0, selectionStart) + value.slice(selectionEnd + 1) + } + } else { + // we have something selected + const firstPart = value.slice(0, selectionStart) + newValue = firstPart + value.slice(selectionEnd) + } + + return {newValue, newSelectionStart: selectionStart} + } + function calculateNewValue(newEntry) { const {selectionStart, selectionEnd} = currentElement() // can't use .maxLength property because of a jsdom bug: From 78af873f78e1d2246c74038a07acdf247741800e Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 11 Jun 2020 00:43:22 -0600 Subject: [PATCH 06/31] fix(clear): make re-use type --- src/user-event/__tests__/clear.js | 32 ++++++++----------------------- src/user-event/clear.js | 22 +++++++++++++++++++++ src/user-event/index.js | 29 ++-------------------------- src/user-event/utils.js | 5 +++++ 4 files changed, 37 insertions(+), 51 deletions(-) create mode 100644 src/user-event/clear.js create mode 100644 src/user-event/utils.js diff --git a/src/user-event/__tests__/clear.js b/src/user-event/__tests__/clear.js index 3160b3ce..5e634ad5 100644 --- a/src/user-event/__tests__/clear.js +++ b/src/user-event/__tests__/clear.js @@ -8,21 +8,13 @@ test('clears text', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value=""] - mouseover: Left (0) - mousemove: Left (0) - mousedown: Left (0) focus - mouseup: Left (0) - click: Left (0) - mousedown: Left (0) - mouseup: Left (0) - click: Left (0) - dblclick: Left (0) select - keydown: Backspace (8) - keyup: Backspace (8) - input: "{SELECTION}hello{/SELECTION}" -> "hello" - change + select + keydown: Delete (46) + input: "{SELECTION}hello{/SELECTION}" -> "" + select + keyup: Delete (46) `) }) @@ -42,19 +34,11 @@ test('does not clear text on readonly inputs', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: input[value="hello"] - mouseover: Left (0) - mousemove: Left (0) - mousedown: Left (0) focus - mouseup: Left (0) - click: Left (0) - mousedown: Left (0) - mouseup: Left (0) - click: Left (0) - dblclick: Left (0) select - keydown: Backspace (8) - keyup: Backspace (8) + select + keydown: Delete (46) + keyup: Delete (46) `) }) diff --git a/src/user-event/clear.js b/src/user-event/clear.js new file mode 100644 index 00000000..1a3ddc2d --- /dev/null +++ b/src/user-event/clear.js @@ -0,0 +1,22 @@ +import {wrapAsync} from '../wrap-async' +import {isInputElement} from './utils' +import {type} from './type' + +async function clear(element) { + if (element.disabled) return + // TODO: track the selection range ourselves so we don't have to do this input "type" trickery + // just like cypress does: https://github.com/cypress-io/cypress/blob/8d7f1a0bedc3c45a2ebf1ff50324b34129fdc683/packages/driver/src/dom/selection.ts#L16-L37 + const elementType = element.type + // type is a readonly property on textarea, so check if element is an input before trying to modify it + if (isInputElement(element)) { + // setSelectionRange is not supported on certain types of inputs, e.g. "number" or "email" + element.type = 'text' + } + await type(element, '{selectall}{del}') + if (isInputElement(element)) { + element.type = elementType + } +} +clear = wrapAsync(clear) + +export {clear} diff --git a/src/user-event/index.js b/src/user-event/index.js index 46e92c55..03ffcf10 100644 --- a/src/user-event/index.js +++ b/src/user-event/index.js @@ -1,5 +1,6 @@ import {wrapAsync} from '../wrap-async' import {fireEvent} from './tick-fire-event' +import {isInputElement} from './utils' import {type} from './type' import {tick} from './tick' @@ -219,24 +220,6 @@ async function backspace(element) { } } -async function selectAll(element) { - await dblClick(element) // simulate events (will not actually select) - const elementType = element.type - // type is a readonly property on textarea, so check if element is an input before trying to modify it - if (isInputElement(element)) { - // setSelectionRange is not supported on certain types of inputs, e.g. "number" or "email" - element.type = 'text' - } - element.setSelectionRange(0, element.value.length) - if (isInputElement(element)) { - element.type = elementType - } -} - -function isInputElement(element) { - return element.tagName.toLowerCase() === 'input' -} - function getPreviouslyFocusedElement(element) { const focusedElement = element.ownerDocument.activeElement const wasAnotherElementFocused = @@ -367,14 +350,6 @@ async function toggleSelectOptions(element, values, init) { } toggleSelectOptions = wrapAsync(toggleSelectOptions) -async function clear(element) { - if (element.disabled) return - - await selectAll(element) - await backspace(element) -} -clear = wrapAsync(clear) - async function upload(element, fileOrFiles, {clickInit, changeInit} = {}) { if (element.disabled) return const focusedElement = element.ownerDocument.activeElement @@ -496,13 +471,13 @@ export { dblClick, selectOptions, toggleSelectOptions, - clear, type, upload, tab, hover, unhover, } +export {clear} from './clear' /* eslint diff --git a/src/user-event/utils.js b/src/user-event/utils.js new file mode 100644 index 00000000..fcc5f6f5 --- /dev/null +++ b/src/user-event/utils.js @@ -0,0 +1,5 @@ +function isInputElement(element) { + return element.tagName.toLowerCase() === 'input' +} + +export {isInputElement} From 02c5018d2390b7e284b482e3c3452450a286a122 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 11 Jun 2020 00:56:40 -0600 Subject: [PATCH 07/31] chore: move things to individual files --- src/user-event/click.js | 151 ++++++++ src/user-event/hover.js | 18 + src/user-event/index.js | 491 +----------------------- src/user-event/select-options.js | 54 +++ src/user-event/tab.js | 73 ++++ src/user-event/tick-fire-event.js | 18 - src/user-event/toggle-select-options.js | 56 +++ src/user-event/type.js | 2 +- src/user-event/upload.js | 32 ++ src/user-event/utils.js | 103 ++++- 10 files changed, 494 insertions(+), 504 deletions(-) create mode 100644 src/user-event/click.js create mode 100644 src/user-event/hover.js create mode 100644 src/user-event/select-options.js create mode 100644 src/user-event/tab.js delete mode 100644 src/user-event/tick-fire-event.js create mode 100644 src/user-event/toggle-select-options.js create mode 100644 src/user-event/upload.js diff --git a/src/user-event/click.js b/src/user-event/click.js new file mode 100644 index 00000000..47133931 --- /dev/null +++ b/src/user-event/click.js @@ -0,0 +1,151 @@ +import {wrapAsync} from '../wrap-async' +import { + fireEvent, + getMouseEventOptions, + getPreviouslyFocusedElement, +} from './utils' + +async function clickLabel(label, init) { + await fireEvent.mouseOver(label, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(label, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(label, getMouseEventOptions('mousedown', init)) + await fireEvent.mouseUp(label, getMouseEventOptions('mouseup', init)) + await fireEvent.click(label, getMouseEventOptions('click', init)) + + // clicking the label will trigger a click of the label.control + // however, it will not focus the label.control so we have to do it + // ourselves. + if (label.control) label.control.focus() +} + +async function clickBooleanElement(element, init) { + if (element.disabled) return + + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(element) + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) + await fireEvent.click(element, getMouseEventOptions('click', init)) +} + +async function clickElement(element, previousElement, init) { + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + const continueDefaultHandling = await fireEvent.mouseDown( + element, + getMouseEventOptions('mousedown', init), + ) + const shouldFocus = element.ownerDocument.activeElement !== element + if (continueDefaultHandling) { + if (previousElement) previousElement.blur() + if (shouldFocus) element.focus() + } + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) + await fireEvent.click(element, getMouseEventOptions('click', init, 1)) + const parentLabel = element.closest('label') + if (parentLabel?.control) parentLabel?.control.focus?.() +} + +async function dblClickElement(element, previousElement, init) { + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + const continueDefaultHandling = await fireEvent.mouseDown( + element, + getMouseEventOptions('mousedown', init), + ) + const shouldFocus = element.ownerDocument.activeElement !== element + if (continueDefaultHandling) { + if (previousElement) previousElement.blur() + if (shouldFocus) element.focus() + } + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) + await fireEvent.click(element, getMouseEventOptions('click', init, 1)) + const parentLabel = element.closest('label') + if (parentLabel?.control) parentLabel?.control.focus?.() + + await fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init, 1)) + await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init, 1)) + await fireEvent.click(element, getMouseEventOptions('click', init, 2)) + await fireEvent.dblClick(element, getMouseEventOptions('dblclick', init, 2)) +} + +async function dblClickCheckbox(checkbox, init) { + await fireEvent.mouseOver(checkbox, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(checkbox, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(checkbox, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(checkbox) + await fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init)) + await fireEvent.click(checkbox, getMouseEventOptions('click', init, 1)) + await fireEvent.mouseDown( + checkbox, + getMouseEventOptions('mousedown', init, 1), + ) + await fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init, 1)) + await fireEvent.click(checkbox, getMouseEventOptions('click', init, 2)) +} + +async function click(element, init) { + const previouslyFocusedElement = getPreviouslyFocusedElement(element) + if (previouslyFocusedElement) { + await fireEvent.mouseMove( + previouslyFocusedElement, + getMouseEventOptions('mousemove', init), + ) + await fireEvent.mouseLeave( + previouslyFocusedElement, + getMouseEventOptions('mouseleave', init), + ) + } + + switch (element.tagName) { + case 'LABEL': + await clickLabel(element, init) + break + case 'INPUT': + if (element.type === 'checkbox' || element.type === 'radio') { + await clickBooleanElement(element, init) + break + } + // eslint-disable-next-line no-fallthrough + default: + await clickElement(element, previouslyFocusedElement, init) + } +} +click = wrapAsync(click) + +async function dblClick(element, init) { + const previouslyFocusedElement = getPreviouslyFocusedElement(element) + if (previouslyFocusedElement) { + await fireEvent.mouseMove( + previouslyFocusedElement, + getMouseEventOptions('mousemove', init), + ) + await fireEvent.mouseLeave( + previouslyFocusedElement, + getMouseEventOptions('mouseleave', init), + ) + } + + switch (element.tagName) { + case 'INPUT': + if (element.type === 'checkbox') { + await dblClickCheckbox(element, previouslyFocusedElement, init) + break + } + // eslint-disable-next-line no-fallthrough + default: + await dblClickElement(element, previouslyFocusedElement, init) + } +} +dblClick = wrapAsync(dblClick) + +export { + click, + dblClick, + clickLabel, + clickBooleanElement, + clickElement, + dblClickElement, + dblClickCheckbox, +} diff --git a/src/user-event/hover.js b/src/user-event/hover.js new file mode 100644 index 00000000..a57da9f6 --- /dev/null +++ b/src/user-event/hover.js @@ -0,0 +1,18 @@ +import {wrapAsync} from '../wrap-async' +import {fireEvent, getMouseEventOptions} from './utils' + +async function hover(element, init) { + await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseEnter(element, getMouseEventOptions('mouseenter', init)) + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) +} +hover = wrapAsync(hover) + +async function unhover(element, init) { + await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseOut(element, getMouseEventOptions('mouseout', init)) + await fireEvent.mouseLeave(element, getMouseEventOptions('mouseleave', init)) +} +unhover = wrapAsync(unhover) + +export {hover, unhover} diff --git a/src/user-event/index.js b/src/user-event/index.js index 03ffcf10..e66251b9 100644 --- a/src/user-event/index.js +++ b/src/user-event/index.js @@ -1,485 +1,8 @@ -import {wrapAsync} from '../wrap-async' -import {fireEvent} from './tick-fire-event' -import {isInputElement} from './utils' -import {type} from './type' -import {tick} from './tick' - -function isMousePressEvent(event) { - return ( - event === 'mousedown' || - event === 'mouseup' || - event === 'click' || - event === 'dblclick' - ) -} - -function invert(map) { - const res = {} - for (const key of Object.keys(map)) { - res[map[key]] = key - } - - return res -} - -// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons -const BUTTONS_TO_NAMES = { - 0: 'none', - 1: 'primary', - 2: 'secondary', - 4: 'auxiliary', -} -const NAMES_TO_BUTTONS = invert(BUTTONS_TO_NAMES) - -// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button -const BUTTON_TO_NAMES = { - 0: 'primary', - 1: 'auxiliary', - 2: 'secondary', -} - -const NAMES_TO_BUTTON = invert(BUTTON_TO_NAMES) - -function convertMouseButtons(event, init, property, mapping) { - if (!isMousePressEvent(event)) { - return 0 - } - - if (init[property] != null) { - return init[property] - } - - if (init.buttons != null) { - return mapping[BUTTONS_TO_NAMES[init.buttons]] || 0 - } - - if (init.button != null) { - return mapping[BUTTON_TO_NAMES[init.button]] || 0 - } - - return property != 'button' && isMousePressEvent(event) ? 1 : 0 -} - -function getMouseEventOptions(event, init, clickCount = 0) { - init = init || {} - return { - ...init, - // https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail - detail: - event === 'mousedown' || event === 'mouseup' - ? 1 + clickCount - : clickCount, - buttons: convertMouseButtons(event, init, 'buttons', NAMES_TO_BUTTONS), - button: convertMouseButtons(event, init, 'button', NAMES_TO_BUTTON), - } -} - -async function clickLabel(label, init) { - await fireEvent.mouseOver(label, getMouseEventOptions('mouseover', init)) - await fireEvent.mouseMove(label, getMouseEventOptions('mousemove', init)) - await fireEvent.mouseDown(label, getMouseEventOptions('mousedown', init)) - await fireEvent.mouseUp(label, getMouseEventOptions('mouseup', init)) - await fireEvent.click(label, getMouseEventOptions('click', init)) - - // clicking the label will trigger a click of the label.control - // however, it will not focus the label.control so we have to do it - // ourselves. - if (label.control) label.control.focus() -} - -async function clickBooleanElement(element, init) { - if (element.disabled) return - - await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) - await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) - await fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init)) - await fireEvent.focus(element) - await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) - await fireEvent.click(element, getMouseEventOptions('click', init)) -} - -async function clickElement(element, previousElement, init) { - await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) - await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) - const continueDefaultHandling = await fireEvent.mouseDown( - element, - getMouseEventOptions('mousedown', init), - ) - const shouldFocus = element.ownerDocument.activeElement !== element - if (continueDefaultHandling) { - if (previousElement) previousElement.blur() - if (shouldFocus) element.focus() - } - await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) - await fireEvent.click(element, getMouseEventOptions('click', init, 1)) - const parentLabel = element.closest('label') - if (parentLabel?.control) parentLabel?.control.focus?.() -} - -async function dblClickElement(element, previousElement, init) { - await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) - await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) - const continueDefaultHandling = await fireEvent.mouseDown( - element, - getMouseEventOptions('mousedown', init), - ) - const shouldFocus = element.ownerDocument.activeElement !== element - if (continueDefaultHandling) { - if (previousElement) previousElement.blur() - if (shouldFocus) element.focus() - } - await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init)) - await fireEvent.click(element, getMouseEventOptions('click', init, 1)) - const parentLabel = element.closest('label') - if (parentLabel?.control) parentLabel?.control.focus?.() - - await fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init, 1)) - await fireEvent.mouseUp(element, getMouseEventOptions('mouseup', init, 1)) - await fireEvent.click(element, getMouseEventOptions('click', init, 2)) - await fireEvent.dblClick(element, getMouseEventOptions('dblclick', init, 2)) -} - -async function dblClickCheckbox(checkbox, init) { - await fireEvent.mouseOver(checkbox, getMouseEventOptions('mouseover', init)) - await fireEvent.mouseMove(checkbox, getMouseEventOptions('mousemove', init)) - await fireEvent.mouseDown(checkbox, getMouseEventOptions('mousedown', init)) - await fireEvent.focus(checkbox) - await fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init)) - await fireEvent.click(checkbox, getMouseEventOptions('click', init, 1)) - await fireEvent.mouseDown( - checkbox, - getMouseEventOptions('mousedown', init, 1), - ) - await fireEvent.mouseUp(checkbox, getMouseEventOptions('mouseup', init, 1)) - await fireEvent.click(checkbox, getMouseEventOptions('click', init, 2)) -} - -async function selectOption(select, option, init) { - await fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) - await fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) - await fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) - await fireEvent.focus(option) - await fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) - await fireEvent.click(option, getMouseEventOptions('click', init, 1)) - - option.selected = true - - await fireEvent.change(select) -} - -async function toggleSelectOption(select, option, init) { - await fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) - await fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) - await fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) - await fireEvent.focus(option) - await fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) - await fireEvent.click(option, getMouseEventOptions('click', init, 1)) - - option.selected = !option.selected - - await fireEvent.change(select) -} - -const Keys = { - Backspace: {keyCode: 8, code: 'Backspace', key: 'Backspace'}, -} - -async function backspace(element) { - const keyboardEventOptions = { - key: Keys.Backspace.key, - keyCode: Keys.Backspace.keyCode, - which: Keys.Backspace.keyCode, - } - await fireEvent.keyDown(element, keyboardEventOptions) - await fireEvent.keyUp(element, keyboardEventOptions) - - if (!element.readOnly) { - await fireEvent.input(element, { - inputType: 'deleteContentBackward', - }) - - // We need to call `await fireEvent.change` _before_ we change `element.value` - // because `await fireEvent.change` will use the element's native value setter - // (meaning it will avoid prototype overrides implemented by React). If we - // call `input.value = ""` first, React will swallow the change event (this - // is checked in the tests). `await fireEvent.change` will only call the native - // value setter method if the event options include `{ target: { value }}` - // (https://github.com/testing-library/dom-testing-library/blob/8846eaf20972f8e41ed11f278948ac38a692c3f1/src/events.js#L29-L32). - // - // Also, we still must call `element.value = ""` after calling - // `await fireEvent.change` because `await fireEvent.change` will _only_ call the native - // `value` setter and not the prototype override defined by React, causing - // React's internal represetation of this state to get out of sync with the - // value set on `input.value`; calling `element.value` after will also call - // React's setter, keeping everything in sync. - // - // Comment either of these out or re-order them and see what parts of the - // tests fail for more context. - await fireEvent.change(element, {target: {value: ''}}) - element.value = '' - } -} - -function getPreviouslyFocusedElement(element) { - const focusedElement = element.ownerDocument.activeElement - const wasAnotherElementFocused = - focusedElement && - focusedElement !== element.ownerDocument.body && - focusedElement !== element - return wasAnotherElementFocused ? focusedElement : null -} - -async function click(element, init) { - const previouslyFocusedElement = getPreviouslyFocusedElement(element) - if (previouslyFocusedElement) { - await fireEvent.mouseMove( - previouslyFocusedElement, - getMouseEventOptions('mousemove', init), - ) - await fireEvent.mouseLeave( - previouslyFocusedElement, - getMouseEventOptions('mouseleave', init), - ) - } - - switch (element.tagName) { - case 'LABEL': - await clickLabel(element, init) - break - case 'INPUT': - if (element.type === 'checkbox' || element.type === 'radio') { - await clickBooleanElement(element, init) - break - } - // eslint-disable-next-line no-fallthrough - default: - await clickElement(element, previouslyFocusedElement, init) - } -} -click = wrapAsync(click) - -async function dblClick(element, init) { - const previouslyFocusedElement = getPreviouslyFocusedElement(element) - if (previouslyFocusedElement) { - await fireEvent.mouseMove( - previouslyFocusedElement, - getMouseEventOptions('mousemove', init), - ) - await fireEvent.mouseLeave( - previouslyFocusedElement, - getMouseEventOptions('mouseleave', init), - ) - } - - switch (element.tagName) { - case 'INPUT': - if (element.type === 'checkbox') { - await dblClickCheckbox(element, previouslyFocusedElement, init) - break - } - // eslint-disable-next-line no-fallthrough - default: - await dblClickElement(element, previouslyFocusedElement, init) - } -} -dblClick = wrapAsync(dblClick) - -async function selectOptions(element, values, init) { - const previouslyFocusedElement = getPreviouslyFocusedElement(element) - if (previouslyFocusedElement) { - await fireEvent.mouseMove( - previouslyFocusedElement, - getMouseEventOptions('mousemove', init), - ) - await fireEvent.mouseLeave( - previouslyFocusedElement, - getMouseEventOptions('mouseleave', init), - ) - } - - await clickElement(element, previouslyFocusedElement, init) - - const valArray = Array.isArray(values) ? values : [values] - const selectedOptions = Array.from(element.querySelectorAll('option')).filter( - opt => valArray.includes(opt.value) || valArray.includes(opt), - ) - - if (selectedOptions.length > 0) { - if (element.multiple) { - for (const option of selectedOptions) { - await selectOption(element, option) - } - } else { - await selectOption(element, selectedOptions[0]) - } - } -} -selectOptions = wrapAsync(selectOptions) - -async function toggleSelectOptions(element, values, init) { - if (!element || element.tagName !== 'SELECT' || !element.multiple) { - throw new Error( - `Unable to toggleSelectOptions - please provide a select element with multiple=true`, - ) - } - - const previouslyFocusedElement = getPreviouslyFocusedElement(element) - if (previouslyFocusedElement) { - await fireEvent.mouseMove( - previouslyFocusedElement, - getMouseEventOptions('mousemove', init), - ) - await fireEvent.mouseLeave( - previouslyFocusedElement, - getMouseEventOptions('mouseleave', init), - ) - } - - await clickElement(element, previouslyFocusedElement, init) - - const valArray = Array.isArray(values) ? values : [values] - const selectedOptions = Array.from(element.querySelectorAll('option')).filter( - opt => valArray.includes(opt.value) || valArray.includes(opt), - ) - - if (selectedOptions.length > 0) { - for (const option of selectedOptions) { - await toggleSelectOption(element, option, init) - } - } -} -toggleSelectOptions = wrapAsync(toggleSelectOptions) - -async function upload(element, fileOrFiles, {clickInit, changeInit} = {}) { - if (element.disabled) return - const focusedElement = element.ownerDocument.activeElement - - let files - - if (element.tagName === 'LABEL') { - await clickLabel(element) - files = element.control.multiple ? fileOrFiles : [fileOrFiles] - } else { - files = element.multiple ? fileOrFiles : [fileOrFiles] - await clickElement(element, focusedElement, clickInit) - } - - await fireEvent.change(element, { - target: { - files: { - length: files.length, - item: index => files[index] || null, - ...files, - }, - }, - ...changeInit, - }) -} -upload = wrapAsync(upload) - -async function tab({shift = false, focusTrap = document} = {}) { - // everything in user-event must be actually async, but since we're not - // calling fireEvent in here, we'll add this tick here... - await tick() - - const focusableElements = focusTrap.querySelectorAll( - 'input, button, select, textarea, a[href], [tabindex]', - ) - - const enabledElements = [...focusableElements].filter( - el => el.getAttribute('tabindex') !== '-1' && !el.disabled, - ) - - if (enabledElements.length === 0) return - - const orderedElements = enabledElements - .map((el, idx) => ({el, idx})) - .sort((a, b) => { - const tabIndexA = a.el.getAttribute('tabindex') - const tabIndexB = b.el.getAttribute('tabindex') - - const diff = tabIndexA - tabIndexB - - return diff === 0 ? a.idx - b.idx : diff - }) - .map(({el}) => el) - - if (shift) orderedElements.reverse() - - // keep only the checked or first element in each radio group - const prunedElements = [] - for (const el of orderedElements) { - if (el.type === 'radio' && el.name) { - const replacedIndex = prunedElements.findIndex( - ({name}) => name === el.name, - ) - - if (replacedIndex === -1) { - prunedElements.push(el) - } else if (el.checked) { - prunedElements.splice(replacedIndex, 1) - prunedElements.push(el) - } - } else { - prunedElements.push(el) - } - } - - if (shift) prunedElements.reverse() - - const index = prunedElements.findIndex( - el => el === el.ownerDocument.activeElement, - ) - - const nextIndex = shift ? index - 1 : index + 1 - const defaultIndex = shift ? prunedElements.length - 1 : 0 - - const next = prunedElements[nextIndex] || prunedElements[defaultIndex] - - if (next.getAttribute('tabindex') === null) { - next.setAttribute('tabindex', '0') // jsdom requires tabIndex=0 for an item to become 'document.activeElement' - next.focus() - next.removeAttribute('tabindex') // leave no trace. :) - } else { - next.focus() - } -} -tab = wrapAsync(tab) - -async function hover(element, init) { - await tick() - await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) - await tick() - await fireEvent.mouseEnter(element, getMouseEventOptions('mouseenter', init)) - await tick() - await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) -} -hover = wrapAsync(hover) - -async function unhover(element, init) { - await tick() - await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) - await tick() - await fireEvent.mouseOut(element, getMouseEventOptions('mouseout', init)) - await tick() - await fireEvent.mouseLeave(element, getMouseEventOptions('mouseleave', init)) -} -unhover = wrapAsync(unhover) - -export { - click, - dblClick, - selectOptions, - toggleSelectOptions, - type, - upload, - tab, - hover, - unhover, -} +export {toggleSelectOptions} from './toggle-select-options' +export {click, dblClick} from './click' +export {type} from './type' export {clear} from './clear' - -/* -eslint - max-depth: ["error", 6], -*/ +export {tab} from './tab' +export {hover, unhover} from './hover' +export {upload} from './upload' +export {selectOptions} from './select-options' diff --git a/src/user-event/select-options.js b/src/user-event/select-options.js new file mode 100644 index 00000000..6af56074 --- /dev/null +++ b/src/user-event/select-options.js @@ -0,0 +1,54 @@ +import {wrapAsync} from '../wrap-async' +import { + fireEvent, + getMouseEventOptions, + getPreviouslyFocusedElement, +} from './utils' +import {clickElement} from './click' + +async function selectOption(select, option, init) { + await fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(option) + await fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) + await fireEvent.click(option, getMouseEventOptions('click', init, 1)) + + option.selected = true + + await fireEvent.change(select) +} + +async function selectOptions(element, values, init) { + const previouslyFocusedElement = getPreviouslyFocusedElement(element) + if (previouslyFocusedElement) { + await fireEvent.mouseMove( + previouslyFocusedElement, + getMouseEventOptions('mousemove', init), + ) + await fireEvent.mouseLeave( + previouslyFocusedElement, + getMouseEventOptions('mouseleave', init), + ) + } + + await clickElement(element, previouslyFocusedElement, init) + + const valArray = Array.isArray(values) ? values : [values] + const selectedOptions = Array.from(element.querySelectorAll('option')).filter( + opt => valArray.includes(opt.value) || valArray.includes(opt), + ) + + if (selectedOptions.length > 0) { + if (element.multiple) { + for (const option of selectedOptions) { + await selectOption(element, option) + } + } else { + await selectOption(element, selectedOptions[0]) + } + } +} +selectOptions = wrapAsync(selectOptions) + +export {selectOptions} diff --git a/src/user-event/tab.js b/src/user-event/tab.js new file mode 100644 index 00000000..35eece9c --- /dev/null +++ b/src/user-event/tab.js @@ -0,0 +1,73 @@ +import {wrapAsync} from '../wrap-async' +import {tick} from './tick' + +async function tab({shift = false, focusTrap = document} = {}) { + // everything in user-event must be actually async, but since we're not + // calling fireEvent in here, we'll add this tick here... + await tick() + + const focusableElements = focusTrap.querySelectorAll( + 'input, button, select, textarea, a[href], [tabindex]', + ) + + const enabledElements = [...focusableElements].filter( + el => el.getAttribute('tabindex') !== '-1' && !el.disabled, + ) + + if (enabledElements.length === 0) return + + const orderedElements = enabledElements + .map((el, idx) => ({el, idx})) + .sort((a, b) => { + const tabIndexA = a.el.getAttribute('tabindex') + const tabIndexB = b.el.getAttribute('tabindex') + + const diff = tabIndexA - tabIndexB + + return diff === 0 ? a.idx - b.idx : diff + }) + .map(({el}) => el) + + if (shift) orderedElements.reverse() + + // keep only the checked or first element in each radio group + const prunedElements = [] + for (const el of orderedElements) { + if (el.type === 'radio' && el.name) { + const replacedIndex = prunedElements.findIndex( + ({name}) => name === el.name, + ) + + if (replacedIndex === -1) { + prunedElements.push(el) + } else if (el.checked) { + prunedElements.splice(replacedIndex, 1) + prunedElements.push(el) + } + } else { + prunedElements.push(el) + } + } + + if (shift) prunedElements.reverse() + + const index = prunedElements.findIndex( + el => el === el.ownerDocument.activeElement, + ) + + const nextIndex = shift ? index - 1 : index + 1 + const defaultIndex = shift ? prunedElements.length - 1 : 0 + + const next = prunedElements[nextIndex] || prunedElements[defaultIndex] + + if (next.getAttribute('tabindex') === null) { + next.setAttribute('tabindex', '0') // jsdom requires tabIndex=0 for an item to become 'document.activeElement' + next.focus() + next.removeAttribute('tabindex') // leave no trace. :) + } else { + next.focus() + } +} +tab = wrapAsync(tab) + +export {tab} diff --git a/src/user-event/tick-fire-event.js b/src/user-event/tick-fire-event.js deleted file mode 100644 index 457cd363..00000000 --- a/src/user-event/tick-fire-event.js +++ /dev/null @@ -1,18 +0,0 @@ -import {fireEvent as baseFireEvent} from '../events' -import {tick} from './tick' - -async function fireEvent(...args) { - await tick() - return baseFireEvent(...args) -} - -Object.keys(baseFireEvent).forEach(key => { - async function asyncFireEventWrapper(...args) { - await tick() - return baseFireEvent[key](...args) - } - Object.defineProperty(asyncFireEventWrapper, 'name', {value: key}) - fireEvent[key] = asyncFireEventWrapper -}) - -export {fireEvent} diff --git a/src/user-event/toggle-select-options.js b/src/user-event/toggle-select-options.js new file mode 100644 index 00000000..287edf5d --- /dev/null +++ b/src/user-event/toggle-select-options.js @@ -0,0 +1,56 @@ +import {wrapAsync} from '../wrap-async' +import { + fireEvent, + getMouseEventOptions, + getPreviouslyFocusedElement, +} from './utils' +import {clickElement} from './click' + +async function toggleSelectOption(select, option, init) { + await fireEvent.mouseOver(option, getMouseEventOptions('mouseover', init)) + await fireEvent.mouseMove(option, getMouseEventOptions('mousemove', init)) + await fireEvent.mouseDown(option, getMouseEventOptions('mousedown', init)) + await fireEvent.focus(option) + await fireEvent.mouseUp(option, getMouseEventOptions('mouseup', init)) + await fireEvent.click(option, getMouseEventOptions('click', init, 1)) + + option.selected = !option.selected + + await fireEvent.change(select) +} + +async function toggleSelectOptions(element, values, init) { + if (!element || element.tagName !== 'SELECT' || !element.multiple) { + throw new Error( + `Unable to toggleSelectOptions - please provide a select element with multiple=true`, + ) + } + + const previouslyFocusedElement = getPreviouslyFocusedElement(element) + if (previouslyFocusedElement) { + await fireEvent.mouseMove( + previouslyFocusedElement, + getMouseEventOptions('mousemove', init), + ) + await fireEvent.mouseLeave( + previouslyFocusedElement, + getMouseEventOptions('mouseleave', init), + ) + } + + await clickElement(element, previouslyFocusedElement, init) + + const valArray = Array.isArray(values) ? values : [values] + const selectedOptions = Array.from(element.querySelectorAll('option')).filter( + opt => valArray.includes(opt.value) || valArray.includes(opt), + ) + + if (selectedOptions.length > 0) { + for (const option of selectedOptions) { + await toggleSelectOption(element, option, init) + } + } +} +toggleSelectOptions = wrapAsync(toggleSelectOptions) + +export {toggleSelectOptions} diff --git a/src/user-event/type.js b/src/user-event/type.js index eb618798..281522c3 100644 --- a/src/user-event/type.js +++ b/src/user-event/type.js @@ -1,5 +1,5 @@ import {wrapAsync} from '../wrap-async' -import {fireEvent} from './tick-fire-event' +import {fireEvent} from './utils' import {tick} from './tick' function wait(time) { diff --git a/src/user-event/upload.js b/src/user-event/upload.js new file mode 100644 index 00000000..845c29a6 --- /dev/null +++ b/src/user-event/upload.js @@ -0,0 +1,32 @@ +import {wrapAsync} from '../wrap-async' +import {fireEvent} from './utils' +import {clickLabel, clickElement} from './click' + +async function upload(element, fileOrFiles, {clickInit, changeInit} = {}) { + if (element.disabled) return + const focusedElement = element.ownerDocument.activeElement + + let files + + if (element.tagName === 'LABEL') { + await clickLabel(element) + files = element.control.multiple ? fileOrFiles : [fileOrFiles] + } else { + files = element.multiple ? fileOrFiles : [fileOrFiles] + await clickElement(element, focusedElement, clickInit) + } + + await fireEvent.change(element, { + target: { + files: { + length: files.length, + item: index => files[index] || null, + ...files, + }, + }, + ...changeInit, + }) +} +upload = wrapAsync(upload) + +export {upload} diff --git a/src/user-event/utils.js b/src/user-event/utils.js index fcc5f6f5..25a45a54 100644 --- a/src/user-event/utils.js +++ b/src/user-event/utils.js @@ -1,5 +1,106 @@ +import {fireEvent as baseFireEvent} from '../events' +import {tick} from './tick' + +async function fireEvent(...args) { + await tick() + return baseFireEvent(...args) +} + +Object.keys(baseFireEvent).forEach(key => { + async function asyncFireEventWrapper(...args) { + await tick() + return baseFireEvent[key](...args) + } + Object.defineProperty(asyncFireEventWrapper, 'name', {value: key}) + fireEvent[key] = asyncFireEventWrapper +}) + function isInputElement(element) { return element.tagName.toLowerCase() === 'input' } -export {isInputElement} +function isMousePressEvent(event) { + return ( + event === 'mousedown' || + event === 'mouseup' || + event === 'click' || + event === 'dblclick' + ) +} + +function invert(map) { + const res = {} + for (const key of Object.keys(map)) { + res[map[key]] = key + } + + return res +} + +// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons +const BUTTONS_TO_NAMES = { + 0: 'none', + 1: 'primary', + 2: 'secondary', + 4: 'auxiliary', +} +const NAMES_TO_BUTTONS = invert(BUTTONS_TO_NAMES) + +// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button +const BUTTON_TO_NAMES = { + 0: 'primary', + 1: 'auxiliary', + 2: 'secondary', +} + +const NAMES_TO_BUTTON = invert(BUTTON_TO_NAMES) + +function convertMouseButtons(event, init, property, mapping) { + if (!isMousePressEvent(event)) { + return 0 + } + + if (init[property] != null) { + return init[property] + } + + if (init.buttons != null) { + return mapping[BUTTONS_TO_NAMES[init.buttons]] || 0 + } + + if (init.button != null) { + return mapping[BUTTON_TO_NAMES[init.button]] || 0 + } + + return property != 'button' && isMousePressEvent(event) ? 1 : 0 +} + +function getMouseEventOptions(event, init, clickCount = 0) { + init = init || {} + return { + ...init, + // https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail + detail: + event === 'mousedown' || event === 'mouseup' + ? 1 + clickCount + : clickCount, + buttons: convertMouseButtons(event, init, 'buttons', NAMES_TO_BUTTONS), + button: convertMouseButtons(event, init, 'button', NAMES_TO_BUTTON), + } +} + +function getPreviouslyFocusedElement(element) { + const focusedElement = element.ownerDocument.activeElement + const wasAnotherElementFocused = + focusedElement && + focusedElement !== element.ownerDocument.body && + focusedElement !== element + return wasAnotherElementFocused ? focusedElement : null +} + +export { + fireEvent, + isInputElement, + getMouseEventOptions, + getPreviouslyFocusedElement, +} From 6956fa864546dba16bbe538792505f687eb6df5a Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 11 Jun 2020 09:22:43 -0600 Subject: [PATCH 08/31] fix(hover): correctness audit on hover --- src/event-map.js | 708 ++++++++++++++-------------- src/user-event/__tests__/hover.js | 3 + src/user-event/__tests__/unhover.js | 3 + src/user-event/hover.js | 6 + 4 files changed, 371 insertions(+), 349 deletions(-) diff --git a/src/event-map.js b/src/event-map.js index 98d78e66..0eba4a28 100644 --- a/src/event-map.js +++ b/src/event-map.js @@ -1,350 +1,360 @@ export const eventMap = { - // Clipboard Events - copy: { - EventType: 'ClipboardEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - cut: { - EventType: 'ClipboardEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - paste: { - EventType: 'ClipboardEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Composition Events - compositionEnd: { - EventType: 'CompositionEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - compositionStart: { - EventType: 'CompositionEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - compositionUpdate: { - EventType: 'CompositionEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Keyboard Events - keyDown: { - EventType: 'KeyboardEvent', - defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, - }, - keyPress: { - EventType: 'KeyboardEvent', - defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, - }, - keyUp: { - EventType: 'KeyboardEvent', - defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, - }, - // Focus Events - focus: { - EventType: 'FocusEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - blur: { - EventType: 'FocusEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - focusIn: { - EventType: 'FocusEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - focusOut: { - EventType: 'FocusEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - // Form Events - change: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: false}, - }, - input: { - EventType: 'InputEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - invalid: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: true}, - }, - submit: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: true}, - }, - reset: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: true}, - }, - // Mouse Events - click: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, button: 0, composed: true}, - }, - contextMenu: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dblClick: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - drag: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dragEnd: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - dragEnter: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dragExit: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - dragLeave: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - dragOver: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dragStart: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - drop: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseDown: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseEnter: { - EventType: 'MouseEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - mouseLeave: { - EventType: 'MouseEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - mouseMove: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseOut: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseOver: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseUp: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Selection Events - select: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: false}, - }, - // Touch Events - touchCancel: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - touchEnd: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - touchMove: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - touchStart: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // UI Events - scroll: { - EventType: 'UIEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - // Wheel Events - wheel: { - EventType: 'WheelEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Media Events - abort: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - canPlay: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - canPlayThrough: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - durationChange: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - emptied: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - encrypted: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - ended: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - loadedData: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - loadedMetadata: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - loadStart: { - EventType: 'ProgressEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - pause: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - play: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - playing: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - progress: { - EventType: 'ProgressEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - rateChange: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - seeked: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - seeking: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - stalled: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - suspend: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - timeUpdate: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - volumeChange: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - waiting: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - // Image Events - load: { - EventType: 'UIEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - error: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - // Animation Events - animationStart: { - EventType: 'AnimationEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - animationEnd: { - EventType: 'AnimationEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - animationIteration: { - EventType: 'AnimationEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - // Transition Events - transitionEnd: { - EventType: 'TransitionEvent', - defaultInit: {bubbles: true, cancelable: true}, - }, - // pointer events - pointerOver: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerEnter: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - pointerDown: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerMove: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerUp: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerCancel: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - pointerOut: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerLeave: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - gotPointerCapture: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - lostPointerCapture: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - // history events - popState: { - EventType: 'PopStateEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - } - - export const eventAliasMap = { - doubleClick: 'dblClick', - } + // Clipboard Events + copy: { + EventType: 'ClipboardEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + cut: { + EventType: 'ClipboardEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + paste: { + EventType: 'ClipboardEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Composition Events + compositionEnd: { + EventType: 'CompositionEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + compositionStart: { + EventType: 'CompositionEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + compositionUpdate: { + EventType: 'CompositionEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Keyboard Events + keyDown: { + EventType: 'KeyboardEvent', + defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, + }, + keyPress: { + EventType: 'KeyboardEvent', + defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, + }, + keyUp: { + EventType: 'KeyboardEvent', + defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, + }, + // Focus Events + focus: { + EventType: 'FocusEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + blur: { + EventType: 'FocusEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + focusIn: { + EventType: 'FocusEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + focusOut: { + EventType: 'FocusEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + // Form Events + change: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: false}, + }, + input: { + EventType: 'InputEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + invalid: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: true}, + }, + submit: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: true}, + }, + reset: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: true}, + }, + // Mouse Events + click: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, button: 0, composed: true}, + }, + contextMenu: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dblClick: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + drag: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dragEnd: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + dragEnter: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dragExit: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + dragLeave: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + dragOver: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dragStart: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + drop: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseDown: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseEnter: { + EventType: 'MouseEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + mouseLeave: { + EventType: 'MouseEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + mouseMove: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseOut: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseOver: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseUp: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Selection Events + select: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: false}, + }, + // Touch Events + touchCancel: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + touchEnd: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + touchMove: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + touchStart: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // UI Events + scroll: { + EventType: 'UIEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + // Wheel Events + wheel: { + EventType: 'WheelEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Media Events + abort: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + canPlay: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + canPlayThrough: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + durationChange: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + emptied: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + encrypted: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + ended: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + loadedData: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + loadedMetadata: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + loadStart: { + EventType: 'ProgressEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + pause: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + play: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + playing: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + progress: { + EventType: 'ProgressEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + rateChange: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + seeked: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + seeking: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + stalled: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + suspend: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + timeUpdate: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + volumeChange: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + waiting: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + // Image Events + load: { + EventType: 'UIEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + error: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + // Animation Events + animationStart: { + EventType: 'AnimationEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, + animationEnd: { + EventType: 'AnimationEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, + animationIteration: { + EventType: 'AnimationEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, + // Transition Events + transitionEnd: { + EventType: 'TransitionEvent', + defaultInit: {bubbles: true, cancelable: true}, + }, + // pointer events + pointerOver: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerEnter: { + EventType: 'PointerEvent', + defaultInit: {bubbles: false, cancelable: false, button: -1}, + }, + pointerDown: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerMove: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerUp: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerCancel: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true, button: -1}, + }, + pointerOut: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerLeave: { + EventType: 'PointerEvent', + defaultInit: {bubbles: false, cancelable: false, button: -1}, + }, + gotPointerCapture: { + EventType: 'PointerEvent', + defaultInit: { + bubbles: false, + cancelable: false, + composed: true, + button: -1, + }, + }, + lostPointerCapture: { + EventType: 'PointerEvent', + defaultInit: { + bubbles: false, + cancelable: false, + composed: true, + button: -1, + }, + }, + // history events + popState: { + EventType: 'PopStateEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, +} + +export const eventAliasMap = { + doubleClick: 'dblClick', +} diff --git a/src/user-event/__tests__/hover.js b/src/user-event/__tests__/hover.js index 91222d08..e3a41020 100644 --- a/src/user-event/__tests__/hover.js +++ b/src/user-event/__tests__/hover.js @@ -8,8 +8,11 @@ test('hover', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: button + pointerover + pointerenter mouseover: Left (0) mouseenter: Left (0) + pointermove mousemove: Left (0) `) }) diff --git a/src/user-event/__tests__/unhover.js b/src/user-event/__tests__/unhover.js index 6306bcf7..d5769fb9 100644 --- a/src/user-event/__tests__/unhover.js +++ b/src/user-event/__tests__/unhover.js @@ -8,7 +8,10 @@ test('unhover', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: button + pointermove mousemove: Left (0) + pointerout + pointerleave mouseout: Left (0) mouseleave: Left (0) `) diff --git a/src/user-event/hover.js b/src/user-event/hover.js index a57da9f6..f521e5e0 100644 --- a/src/user-event/hover.js +++ b/src/user-event/hover.js @@ -2,14 +2,20 @@ import {wrapAsync} from '../wrap-async' import {fireEvent, getMouseEventOptions} from './utils' async function hover(element, init) { + await fireEvent.pointerOver(element, init) + await fireEvent.pointerEnter(element, init) await fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init)) await fireEvent.mouseEnter(element, getMouseEventOptions('mouseenter', init)) + await fireEvent.pointerMove(element, init) await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) } hover = wrapAsync(hover) async function unhover(element, init) { + await fireEvent.pointerMove(element, init) await fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init)) + await fireEvent.pointerOut(element, init) + await fireEvent.pointerLeave(element, init) await fireEvent.mouseOut(element, getMouseEventOptions('mouseout', init)) await fireEvent.mouseLeave(element, getMouseEventOptions('mouseleave', init)) } From 80872eac65d19dd4df18c829edcff216e9447419 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 11 Jun 2020 10:47:19 -0600 Subject: [PATCH 09/31] fix(type): allow typing decimal numbers Ref: https://github.com/testing-library/user-event/commit/ba1c8d37aff3d3355a21ad234eb427e0e727cec0 --- src/user-event/__tests__/type.js | 25 +++++++++++++++++++++++++ src/user-event/type.js | 30 ++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/user-event/__tests__/type.js b/src/user-event/__tests__/type.js index 7b441d50..bfd62590 100644 --- a/src/user-event/__tests__/type.js +++ b/src/user-event/__tests__/type.js @@ -473,6 +473,31 @@ test('can type "-" into number inputs', async () => { `) }) +// https://github.com/testing-library/user-event/issues/336 +test('can type "." into number inputs', async () => { + const {element, getEventCalls} = setup('') + await userEvent.type(element, '0.3') + expect(element).toHaveValue(0.3) + + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=".3"] + + focus + keydown: 0 (48) + keypress: 0 (48) + input: "{CURSOR}" -> "0" + keyup: 0 (48) + keydown: . (46) + keypress: . (46) + input: "{CURSOR}0" -> "" + keyup: . (46) + keydown: 3 (51) + keypress: 3 (51) + input: "{CURSOR}" -> ".3" + keyup: 3 (51) + `) +}) + test('-{backspace}3', async () => { const {element} = setup('') const negativeNumber = '-{backspace}3' diff --git a/src/user-event/type.js b/src/user-event/type.js index 281522c3..572c0bb3 100644 --- a/src/user-event/type.js +++ b/src/user-event/type.js @@ -244,13 +244,18 @@ async function type( } } const eventOverrides = {} - let prevWasMinus + let prevWasMinus, prevWasPeriod for (const callback of eventCallbacks) { if (delay > 0) await wait(delay) if (!currentElement().disabled) { - const returnValue = await callback({prevWasMinus, eventOverrides}) + const returnValue = await callback({ + prevWasMinus, + prevWasPeriod, + eventOverrides, + }) Object.assign(eventOverrides, returnValue?.eventOverrides) prevWasMinus = returnValue?.prevWasMinus + prevWasPeriod = returnValue?.prevWasPeriod } } } @@ -385,10 +390,13 @@ async function type( } } - async function typeCharacter(char, {prevWasMinus = false, eventOverrides}) { + async function typeCharacter( + char, + {prevWasMinus = false, prevWasPeriod = false, eventOverrides}, + ) { const key = char // TODO: check if this also valid for characters with diacritic markers e.g. úé etc const keyCode = char.charCodeAt(0) - let nextPrevWasMinus + let nextPrevWasMinus, nextPrevWasPeriod const keyDownDefaultNotPrevented = await fireEvent.keyDown( currentElement(), @@ -412,7 +420,12 @@ async function type( ) if (keyPressDefaultNotPrevented) { - const newEntry = prevWasMinus ? `-${char}` : char + let newEntry = char + if (prevWasMinus) { + newEntry = `-${char}` + } else if (prevWasPeriod) { + newEntry = `.${char}` + } const {prevValue} = await fireInputEventIfNeeded({ ...calculateNewValue(newEntry), @@ -435,6 +448,11 @@ async function type( } else { nextPrevWasMinus = newEntry === '-' } + if (newValue === prevValue && newEntry !== '.') { + nextPrevWasPeriod = prevWasPeriod + } else { + nextPrevWasPeriod = newEntry === '.' + } } } } @@ -446,7 +464,7 @@ async function type( ...eventOverrides, }) - return {prevWasMinus: nextPrevWasMinus} + return {prevWasMinus: nextPrevWasMinus, prevWasPeriod: nextPrevWasPeriod} } function modifier({name, key, keyCode, modifierProperty}) { From 4a3381ac6b45ca0fcaebe9c3d778e78230e47c1a Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 11 Jun 2020 11:12:28 -0600 Subject: [PATCH 10/31] Update src/user-event/__tests__/tab.js Co-authored-by: Ben Monro --- src/user-event/__tests__/tab.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/user-event/__tests__/tab.js b/src/user-event/__tests__/tab.js index b2159518..817677a2 100644 --- a/src/user-event/__tests__/tab.js +++ b/src/user-event/__tests__/tab.js @@ -85,7 +85,7 @@ test('should respect tabindex, regardless of dom position', async () => { expect(radio).toHaveFocus() }) -test('should respect dom order when tabindex are all the same', async () => { +test('should respect tab index order, then DOM order', async () => { setup(`
From 2e253befbea47ec91e3c3a114a2e5f654c34b25f Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 11 Jun 2020 13:14:08 -0600 Subject: [PATCH 11/31] test: get coverage up --- src/user-event/__tests__/clear.js | 14 + src/user-event/__tests__/click.js | 172 ++++------ src/user-event/__tests__/dblclick.js | 293 +++++++----------- src/user-event/__tests__/select-options.js | 41 ++- .../__tests__/toggle-selectoptions.js | 62 ++-- src/user-event/__tests__/type-modifiers.js | 26 ++ src/user-event/__tests__/upload.js | 27 +- src/user-event/clear.js | 12 +- src/user-event/click.js | 61 ++-- src/user-event/select-options.js | 58 ++-- src/user-event/toggle-select-options.js | 51 ++- src/user-event/type.js | 6 +- src/user-event/upload.js | 6 +- src/user-event/utils.js | 28 +- 14 files changed, 401 insertions(+), 456 deletions(-) diff --git a/src/user-event/__tests__/clear.js b/src/user-event/__tests__/clear.js index 5e634ad5..64b06da9 100644 --- a/src/user-event/__tests__/clear.js +++ b/src/user-event/__tests__/clear.js @@ -18,6 +18,12 @@ test('clears text', async () => { `) }) +test('works with textarea', async () => { + const {element} = setup('') + await userEvent.clear(element) + expect(element).toHaveValue('') +}) + test('does not clear text on disabled inputs', async () => { const {element, getEventCalls} = setup('') await userEvent.clear(element) @@ -56,3 +62,11 @@ test('clears even on inputs that cannot (programmatically) have a selection', as // jest-dom does funny stuff with toHaveValue on number inputs expect(number.value).toBe('') }) + +test('non-inputs/textareas are currently unsupported', async () => { + const {element} = setup('
') + const error = await userEvent.clear(element).catch(e => e) + expect(error).toMatchInlineSnapshot( + `[Error: clear currently only supports input and textarea elements.]`, + ) +}) diff --git a/src/user-event/__tests__/click.js b/src/user-event/__tests__/click.js index 7011c01f..59a3271e 100644 --- a/src/user-event/__tests__/click.js +++ b/src/user-event/__tests__/click.js @@ -11,6 +11,7 @@ test('click in input', async () => { mousemove: Left (0) mousedown: Left (0) focus + focusin mouseup: Left (0) click: Left (0) `) @@ -26,6 +27,7 @@ test('click in textarea', async () => { mousemove: Left (0) mousedown: Left (0) focus + focusin mouseup: Left (0) click: Left (0) `) @@ -103,13 +105,14 @@ test('should fire the correct events for
', async () => { mouseover: Left (0) mousemove: Left (0) mousedown: Left (0) + focusin mouseup: Left (0) click: Left (0) `) }) test('toggles the focus', async () => { - const {element} = setup(`
`) + const {element} = setup(`
`) const a = element.children[0] const b = element.children[1] @@ -127,7 +130,7 @@ test('toggles the focus', async () => { }) test('should blur the previous element', async () => { - const {element} = setup(`
`) + const {element} = setup(`
`) const a = element.children[0] const b = element.children[1] @@ -138,16 +141,15 @@ test('should blur the previous element', async () => { clearEventCalls() await userEvent.click(b) expect(getEventCalls()).toMatchInlineSnapshot(` - Events fired on: input[value=""] + Events fired on: input[name="a"][value=""] - mousemove: Left (0) (bubbled from input[value=""]) - mouseleave: Left (0) blur + focusout (bubbled from input[name="a"][value=""]) `) }) test('should not blur the previous element when mousedown prevents default', async () => { - const {element} = setup(`
`) + const {element} = setup(`
`) const a = element.children[0] const b = element.children[1] @@ -159,12 +161,9 @@ test('should not blur the previous element when mousedown prevents default', asy await userEvent.click(a) clearEventCalls() await userEvent.click(b) - expect(getEventCalls()).toMatchInlineSnapshot(` - Events fired on: input[value=""] - - mousemove: Left (0) (bubbled from input[value=""]) - mouseleave: Left (0) - `) + expect(getEventCalls()).toMatchInlineSnapshot( + `No events were fired on: input[name="a"][value=""]`, + ) }) test('does not lose focus when click updates focus', async () => { @@ -212,6 +211,11 @@ test('gives focus to the form control when clicking within a label', async () => expect(input).toHaveFocus() }) +test('does not crash if the label has no control', async () => { + const {element} = setup(``) + await userEvent.click(element) +}) + test('clicking a label checks the checkbox', async () => { const {element} = setup(`
@@ -281,38 +285,20 @@ test('does not give focus when mouseDown is prevented', async () => { test('fires mouse events with the correct properties', async () => { const {element, getEvents} = setup('
') await userEvent.click(element) - expect(getEvents()).toEqual([ - expect.objectContaining({ - type: 'mouseover', - button: 0, - buttons: 0, - detail: 0, - }), - expect.objectContaining({ - type: 'mousemove', - button: 0, - buttons: 0, - detail: 0, - }), - expect.objectContaining({ - type: 'mousedown', - button: 0, - buttons: 1, - detail: 1, - }), - expect.objectContaining({ - type: 'mouseup', - button: 0, - buttons: 1, - detail: 1, - }), - expect.objectContaining({ - type: 'click', - button: 0, - buttons: 1, - detail: 1, - }), - ]) + const events = getEvents().map( + ({constructor, type, button, buttons, detail}) => + constructor.name === 'MouseEvent' + ? `${type} - button=${button}; buttons=${buttons}; detail=${detail}` + : type, + ) + expect(events.join('\n')).toMatchInlineSnapshot(` + mouseover - button=0; buttons=0; detail=0 + mousemove - button=0; buttons=0; detail=0 + mousedown - button=0; buttons=1; detail=1 + focusin + mouseup - button=0; buttons=1; detail=1 + click - button=0; buttons=1; detail=1 + `) }) test('fires mouse events with custom button property', async () => { @@ -321,80 +307,38 @@ test('fires mouse events with custom button property', async () => { button: 1, altKey: true, }) - expect(getEvents()).toEqual([ - expect.objectContaining({ - type: 'mouseover', - button: 0, - buttons: 0, - detail: 0, - altKey: true, - }), - expect.objectContaining({ - type: 'mousemove', - button: 0, - buttons: 0, - detail: 0, - altKey: true, - }), - expect.objectContaining({ - type: 'mousedown', - button: 1, - buttons: 4, - detail: 1, - altKey: true, - }), - expect.objectContaining({ - type: 'mouseup', - button: 1, - buttons: 4, - detail: 1, - altKey: true, - }), - expect.objectContaining({ - type: 'click', - button: 1, - buttons: 4, - detail: 1, - altKey: true, - }), - ]) + const events = getEvents().map( + ({constructor, type, button, buttons, detail}) => + constructor.name === 'MouseEvent' + ? `${type} - button=${button}; buttons=${buttons}; detail=${detail}` + : type, + ) + expect(events.join('\n')).toMatchInlineSnapshot(` + mouseover - button=0; buttons=0; detail=0 + mousemove - button=0; buttons=0; detail=0 + mousedown - button=1; buttons=4; detail=1 + focusin + mouseup - button=1; buttons=4; detail=1 + click - button=1; buttons=4; detail=1 + `) }) test('fires mouse events with custom buttons property', async () => { const {element, getEvents} = setup('
') await userEvent.click(element, {buttons: 4}) - - expect(getEvents()).toEqual([ - expect.objectContaining({ - type: 'mouseover', - button: 0, - buttons: 0, - detail: 0, - }), - expect.objectContaining({ - type: 'mousemove', - button: 0, - buttons: 0, - detail: 0, - }), - expect.objectContaining({ - type: 'mousedown', - button: 1, - buttons: 4, - detail: 1, - }), - expect.objectContaining({ - type: 'mouseup', - button: 1, - buttons: 4, - detail: 1, - }), - expect.objectContaining({ - type: 'click', - button: 1, - buttons: 4, - detail: 1, - }), - ]) + const events = getEvents().map( + ({constructor, type, button, buttons, detail}) => + constructor.name === 'MouseEvent' + ? `${type} - button=${button}; buttons=${buttons}; detail=${detail}` + : type, + ) + expect(events.join('\n')).toMatchInlineSnapshot(` + mouseover - button=0; buttons=0; detail=0 + mousemove - button=0; buttons=0; detail=0 + mousedown - button=1; buttons=4; detail=1 + focusin + mouseup - button=1; buttons=4; detail=1 + click - button=1; buttons=4; detail=1 + `) }) diff --git a/src/user-event/__tests__/dblclick.js b/src/user-event/__tests__/dblclick.js index d3609a21..524bcb9e 100644 --- a/src/user-event/__tests__/dblclick.js +++ b/src/user-event/__tests__/dblclick.js @@ -42,6 +42,25 @@ test('fires the correct events on checkboxes', async () => { `) }) +test('fires the correct events on regular inputs', async () => { + const {element, getEventCalls} = setup('') + await userEvent.dblClick(element) + expect(getEventCalls()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + mouseover: Left (0) + mousemove: Left (0) + mousedown: Left (0) + focus + mouseup: Left (0) + click: Left (0) + mousedown: Left (0) + mouseup: Left (0) + click: Left (0) + dblclick: Left (0) + `) +}) + test('fires the correct events on divs', async () => { const {element, getEventCalls} = setup('
') await userEvent.dblClick(element) @@ -79,12 +98,45 @@ test('blurs the previous element', async () => { expect(getEventCalls()).toMatchInlineSnapshot(` Events fired on: button#button-a - mousemove: Left (0) (bubbled from button#button-a) - mouseleave: Left (0) blur `) }) +test('does not fire focus event if the element is already focused', async () => { + const {element, clearEventCalls, eventWasFired} = setup(``) + const {element, eventWasFired} = setup(`
`) await userEvent.click(element.children[0]) - expect(getEventCalls()).toContain('submit') + expect(eventWasFired('submit')).toBe(true) }) test('does not submit a form when clicking on a `) await userEvent.click(element.children[0]) - expect(getEventCalls()).not.toContain('submit') + expect(getEventSnapshot()).not.toContain('submit') }) test('does not fire blur on current element if is the same as previous', async () => { - const {element, getEventCalls, clearEventCalls} = setup('
`) +
+ `) const [checkbox, radio, number] = document.querySelectorAll( '[data-testid="element"]', diff --git a/src/user-event/__tests__/toggle-selectoptions.js b/src/user-event/__tests__/toggle-selectoptions.js index 87de34b6..0d0141eb 100644 --- a/src/user-event/__tests__/toggle-selectoptions.js +++ b/src/user-event/__tests__/toggle-selectoptions.js @@ -9,15 +9,15 @@ test('should fire the correct events for multiple select', async () => { expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: select[name="select"][value=["1"]] - select[name="select"][value=[]] - mouseover: Left (0) - selectedOptions: [] -> [] - select[name="select"][value=[]] - mousemove: Left (0) + select[name="select"][value=[]] - pointerdown selectedOptions: [] -> [] select[name="select"][value=[]] - mousedown: Left (0) selectedOptions: [] -> [] select[name="select"][value=[]] - focus select[name="select"][value=[]] - focusin selectedOptions: [] -> [] + select[name="select"][value=[]] - pointerup + selectedOptions: [] -> [] select[name="select"][value=[]] - mouseup: Left (0) selectedOptions: [] -> [] select[name="select"][value=[]] - click: Left (0) @@ -48,11 +48,11 @@ test('should fire the correct events for multiple select when focus is in other expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: form - select[name="select"][value=[]] - mouseover: Left (0) - select[name="select"][value=[]] - mousemove: Left (0) + select[name="select"][value=[]] - pointerdown select[name="select"][value=[]] - mousedown: Left (0) button - focusout select[name="select"][value=[]] - focusin + select[name="select"][value=[]] - pointerup select[name="select"][value=[]] - mouseup: Left (0) select[name="select"][value=[]] - click: Left (0) option[value="1"] - mouseover: Left (0) diff --git a/src/user-event/__tests__/type-modifiers.js b/src/user-event/__tests__/type-modifiers.js index a52d31cf..2699db4f 100644 --- a/src/user-event/__tests__/type-modifiers.js +++ b/src/user-event/__tests__/type-modifiers.js @@ -38,11 +38,11 @@ test('a{backspace}', async () => { input[value=""] - focus input[value=""] - keydown: a (97) input[value=""] - keypress: a (97) - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) input[value="a"] - keydown: Backspace (8) - input[value="a"] - input + input[value=""] - input "a{CURSOR}" -> "{CURSOR}" input[value=""] - keyup: Backspace (8) `) @@ -59,7 +59,7 @@ test('{backspace}a', async () => { input[value=""] - keyup: Backspace (8) input[value=""] - keydown: a (97) input[value=""] - keypress: a (97) - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) `) @@ -77,7 +77,7 @@ test('{backspace} triggers typing the backspace character and deletes the charac input[value="yo"] - select input[value="yo"] - focus input[value="yo"] - keydown: Backspace (8) - input[value="yo"] - input + input[value="o"] - input "y{CURSOR}o" -> "o{CURSOR}" input[value="o"] - select input[value="o"] - keyup: Backspace (8) @@ -130,7 +130,7 @@ test('{backspace} deletes the selected range', async () => { input[value="Hi there"] - select input[value="Hi there"] - focus input[value="Hi there"] - keydown: Backspace (8) - input[value="Hi there"] - input + input[value="Here"] - input "H{SELECTION}i th{/SELECTION}ere" -> "Here{CURSOR}" input[value="Here"] - select input[value="Here"] - keyup: Backspace (8) @@ -157,7 +157,7 @@ test('{alt}a{/alt}', async () => { input[value=""] - keydown: Alt (18) {alt} input[value=""] - keydown: a (97) {alt} input[value=""] - keypress: a (97) {alt} - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) {alt} input[value="a"] - keyup: Alt (18) @@ -176,7 +176,7 @@ test('{meta}a{/meta}', async () => { input[value=""] - keydown: Meta (93) {meta} input[value=""] - keydown: a (97) {meta} input[value=""] - keypress: a (97) {meta} - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) {meta} input[value="a"] - keyup: Meta (93) @@ -195,7 +195,7 @@ test('{ctrl}a{/ctrl}', async () => { input[value=""] - keydown: Control (17) {ctrl} input[value=""] - keydown: a (97) {ctrl} input[value=""] - keypress: a (97) {ctrl} - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) {ctrl} input[value="a"] - keyup: Control (17) @@ -214,7 +214,7 @@ test('{shift}a{/shift}', async () => { input[value=""] - keydown: Shift (16) {shift} input[value=""] - keydown: a (97) {shift} input[value=""] - keypress: a (97) {shift} - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) {shift} input[value="a"] - keyup: Shift (16) @@ -232,7 +232,7 @@ test('a{enter}', async () => { input[value=""] - focus input[value=""] - keydown: a (97) input[value=""] - keypress: a (97) - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) input[value="a"] - keydown: Enter (13) @@ -286,7 +286,7 @@ test('{enter} on a textarea', async () => { textarea[value=""] - focus textarea[value=""] - keydown: Enter (13) textarea[value=""] - keypress: Enter (13) - textarea[value=""] - input + textarea[value="\\n"] - input "{CURSOR}" -> "\\n{CURSOR}" textarea[value="\\n"] - keyup: Enter (13) `) @@ -324,7 +324,7 @@ test('{meta}{alt}{ctrl}a{/ctrl}{/alt}{/meta}', async () => { input[value=""] - keydown: Control (17) {alt}{meta}{ctrl} input[value=""] - keydown: a (97) {alt}{meta}{ctrl} input[value=""] - keypress: a (97) {alt}{meta}{ctrl} - input[value=""] - input + input[value="a"] - input "{CURSOR}" -> "a{CURSOR}" input[value="a"] - keyup: a (97) {alt}{meta}{ctrl} input[value="a"] - keyup: Control (17) {alt}{meta} @@ -349,7 +349,6 @@ test('{selectall} selects all the text', async () => { expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="abcdefg"] - input[value="abcdefg"] - select input[value="abcdefg"] - focus input[value="abcdefg"] - select `) @@ -370,7 +369,7 @@ test('{del} at the start of the input', async () => { input[value="hello"] - focus input[value="hello"] - keydown: Delete (46) - input[value="hello"] - input + input[value="ello"] - input "{CURSOR}hello" -> "ello{CURSOR}" input[value="ello"] - select input[value="ello"] - keyup: Delete (46) @@ -410,7 +409,7 @@ test('{del} in the middle of the input', async () => { input[value="hello"] - focus input[value="hello"] - select input[value="hello"] - keydown: Delete (46) - input[value="hello"] - input + input[value="helo"] - input "he{CURSOR}llo" -> "helo{CURSOR}" input[value="helo"] - select input[value="helo"] - keyup: Delete (46) @@ -433,7 +432,7 @@ test('{del} with a selection range', async () => { input[value="hello"] - focus input[value="hello"] - select input[value="hello"] - keydown: Delete (46) - input[value="hello"] - input + input[value="hlo"] - input "h{SELECTION}el{/SELECTION}lo" -> "hlo{CURSOR}" input[value="hlo"] - select input[value="hlo"] - keyup: Delete (46) diff --git a/src/user-event/__tests__/type.js b/src/user-event/__tests__/type.js index 8a04c574..119a04b6 100644 --- a/src/user-event/__tests__/type.js +++ b/src/user-event/__tests__/type.js @@ -11,17 +11,17 @@ test('types text in input', async () => { input[value=""] - focus input[value=""] - keydown: S (83) input[value=""] - keypress: S (83) - input[value=""] - input + input[value="S"] - input "{CURSOR}" -> "S{CURSOR}" input[value="S"] - keyup: S (83) input[value="S"] - keydown: u (117) input[value="S"] - keypress: u (117) - input[value="S"] - input + input[value="Su"] - input "S{CURSOR}" -> "Su{CURSOR}" input[value="Su"] - keyup: u (117) input[value="Su"] - keydown: p (112) input[value="Su"] - keypress: p (112) - input[value="Su"] - input + input[value="Sup"] - input "Su{CURSOR}" -> "Sup{CURSOR}" input[value="Sup"] - keyup: p (112) `) @@ -34,7 +34,7 @@ test('types text in input with allAtOnce', async () => { Events fired on: input[value="Sup"] input[value=""] - focus - input[value=""] - input + input[value="Sup"] - input "{CURSOR}" -> "Sup{CURSOR}" `) }) @@ -52,17 +52,17 @@ test('types text inside custom element', async () => { input[value=""] - focus input[value=""] - keydown: S (83) input[value=""] - keypress: S (83) - input[value=""] - input + input[value="S"] - input "{CURSOR}" -> "S{CURSOR}" input[value="S"] - keyup: S (83) input[value="S"] - keydown: u (117) input[value="S"] - keypress: u (117) - input[value="S"] - input + input[value="Su"] - input "S{CURSOR}" -> "Su{CURSOR}" input[value="Su"] - keyup: u (117) input[value="Su"] - keydown: p (112) input[value="Su"] - keypress: p (112) - input[value="Su"] - input + input[value="Sup"] - input "Su{CURSOR}" -> "Sup{CURSOR}" input[value="Sup"] - keyup: p (112) `) @@ -77,17 +77,17 @@ test('types text in textarea', async () => { textarea[value=""] - focus textarea[value=""] - keydown: S (83) textarea[value=""] - keypress: S (83) - textarea[value=""] - input + textarea[value="S"] - input "{CURSOR}" -> "S{CURSOR}" textarea[value="S"] - keyup: S (83) textarea[value="S"] - keydown: u (117) textarea[value="S"] - keypress: u (117) - textarea[value="S"] - input + textarea[value="Su"] - input "S{CURSOR}" -> "Su{CURSOR}" textarea[value="Su"] - keyup: u (117) textarea[value="Su"] - keydown: p (112) textarea[value="Su"] - keypress: p (112) - textarea[value="Su"] - input + textarea[value="Sup"] - input "Su{CURSOR}" -> "Sup{CURSOR}" textarea[value="Sup"] - keyup: p (112) `) @@ -100,7 +100,7 @@ test('should append text all at once', async () => { Events fired on: input[value="Sup"] input[value=""] - focus - input[value=""] - input + input[value="Sup"] - input "{CURSOR}" -> "Sup{CURSOR}" `) }) @@ -212,12 +212,12 @@ test('honors maxlength', async () => { input[value=""] - focus input[value=""] - keydown: 1 (49) input[value=""] - keypress: 1 (49) - input[value=""] - input + input[value="1"] - input "{CURSOR}" -> "1{CURSOR}" input[value="1"] - keyup: 1 (49) input[value="1"] - keydown: 2 (50) input[value="1"] - keypress: 2 (50) - input[value="1"] - input + input[value="12"] - input "1{CURSOR}" -> "12{CURSOR}" input[value="12"] - keyup: 2 (50) input[value="12"] - keydown: 3 (51) @@ -315,12 +315,12 @@ test('typing into a controlled input works', async () => { input[value=""] - focus input[value=""] - keydown: 2 (50) input[value=""] - keypress: 2 (50) - input[value=""] - input + input[value="2"] - input "{CURSOR}" -> "$2{CURSOR}" input[value="$2"] - keyup: 2 (50) input[value="$2"] - keydown: 3 (51) input[value="$2"] - keypress: 3 (51) - input[value="$2"] - input + input[value="$23"] - input "$2{CURSOR}" -> "$23{CURSOR}" input[value="$23"] - keyup: 3 (51) `) @@ -340,7 +340,7 @@ test('typing in the middle of a controlled input works', async () => { input[value="$23"] - focus input[value="$23"] - keydown: 1 (49) input[value="$23"] - keypress: 1 (49) - input[value="$23"] - input + input[value="$213"] - input "$2{CURSOR}3" -> "$213{CURSOR}" input[value="$213"] - select input[value="$213"] - keyup: 1 (49) @@ -370,12 +370,12 @@ test('ignored {backspace} in controlled input', async () => { input[value="$23"] - select input[value="$23"] - focus input[value="$23"] - keydown: Backspace (8) - input[value="$23"] - input + input[value="23"] - input "\${CURSOR}23" -> "$23{CURSOR}" input[value="$23"] - keyup: Backspace (8) input[value="$23"] - keydown: 4 (52) input[value="$23"] - keypress: 4 (52) - input[value="$23"] - input + input[value="$234"] - input "$23{CURSOR}" -> "$234{CURSOR}" input[value="$234"] - keyup: 4 (52) `) @@ -393,12 +393,12 @@ test('typing in a textarea with existing text', async () => { textarea[value="Hello, "] - select textarea[value="Hello, "] - keydown: 1 (49) textarea[value="Hello, "] - keypress: 1 (49) - textarea[value="Hello, "] - input + textarea[value="Hello, 1"] - input "Hello, {CURSOR}" -> "Hello, 1{CURSOR}" textarea[value="Hello, 1"] - keyup: 1 (49) textarea[value="Hello, 1"] - keydown: 2 (50) textarea[value="Hello, 1"] - keypress: 2 (50) - textarea[value="Hello, 1"] - input + textarea[value="Hello, 12"] - input "Hello, 1{CURSOR}" -> "Hello, 12{CURSOR}" textarea[value="Hello, 12"] - keyup: 2 (50) `) @@ -421,13 +421,13 @@ test('accepts an initialSelectionStart and initialSelectionEnd', async () => { textarea[value="Hello, "] - focus textarea[value="Hello, "] - keydown: 1 (49) textarea[value="Hello, "] - keypress: 1 (49) - textarea[value="Hello, "] - input + textarea[value="1Hello, "] - input "{CURSOR}Hello, " -> "1Hello, {CURSOR}" textarea[value="1Hello, "] - select textarea[value="1Hello, "] - keyup: 1 (49) textarea[value="1Hello, "] - keydown: 2 (50) textarea[value="1Hello, "] - keypress: 2 (50) - textarea[value="1Hello, "] - input + textarea[value="12Hello, "] - input "1{CURSOR}Hello, " -> "12Hello, {CURSOR}" textarea[value="12Hello, "] - select textarea[value="12Hello, "] - keyup: 2 (50) @@ -463,7 +463,7 @@ test('can type "-" into number inputs', async () => { input[value=""] - keyup: - (45) input[value=""] - keydown: 3 (51) input[value=""] - keypress: 3 (51) - input[value=""] - input + input[value="-3"] - input "{CURSOR}" -> "{CURSOR}-3" input[value="-3"] - keyup: 3 (51) `) @@ -481,17 +481,17 @@ test('can type "." into number inputs', async () => { input[value=""] - focus input[value=""] - keydown: 0 (48) input[value=""] - keypress: 0 (48) - input[value=""] - input + input[value="0"] - input "{CURSOR}" -> "{CURSOR}0" input[value="0"] - keyup: 0 (48) input[value="0"] - keydown: . (46) input[value="0"] - keypress: . (46) - input[value="0"] - input + input[value=""] - input "{CURSOR}0" -> "{CURSOR}" input[value=""] - keyup: . (46) input[value=""] - keydown: 3 (51) input[value=""] - keypress: 3 (51) - input[value=""] - input + input[value=".3"] - input "{CURSOR}" -> "{CURSOR}.3" input[value=".3"] - keyup: 3 (51) `) diff --git a/src/user-event/__tests__/unhover.js b/src/user-event/__tests__/unhover.js index 4ff814f4..00af2b20 100644 --- a/src/user-event/__tests__/unhover.js +++ b/src/user-event/__tests__/unhover.js @@ -16,3 +16,25 @@ test('unhover', async () => { button - mouseleave: Left (0) `) }) + +test('unhover on disabled element', async () => { + const {element, getEventSnapshot} = setup('