From ca214d4f55c963f8daccf1b1e3458bf36db4a58c Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 25 Nov 2021 12:41:18 +0100 Subject: [PATCH] feat!: async APIs (#790) BREAKING CHANGE: APIs always return a Promise. --- .eslintrc.js | 1 + src/.eslintrc | 8 - src/click.ts | 58 ---- src/clipboard/copy.ts | 29 +- src/clipboard/cut.ts | 27 +- src/clipboard/paste.ts | 43 +-- src/convenience/click.ts | 45 +++ src/convenience/hover.ts | 22 ++ src/convenience/index.ts | 3 + src/convenience/tab.ts | 18 + src/hover.ts | 37 --- src/index.ts | 22 +- src/keyboard/getNextKeyDef.ts | 6 +- src/keyboard/index.ts | 64 +--- src/keyboard/keyboardImplementation.ts | 138 +++----- src/keyboard/plugins/character.ts | 16 +- src/keyboard/plugins/combination.ts | 16 +- src/keyboard/plugins/functional.ts | 36 +- src/keyboard/types.ts | 26 +- src/options.ts | 132 ++++++++ src/pointer/index.ts | 63 +--- src/pointer/parseKeyDef.ts | 4 +- src/pointer/pointerAction.ts | 41 +-- src/pointer/pointerMove.ts | 3 +- src/pointer/pointerPress.ts | 2 +- src/pointer/types.ts | 13 - src/setup.ts | 260 --------------- src/setup/api.ts | 5 + src/setup/config.ts | 11 + src/setup/directApi.ts | 104 ++++++ src/setup/index.ts | 19 ++ src/setup/setup.ts | 80 +++++ src/setup/wrapAsync.ts | 10 + src/tab.ts | 15 - src/type.ts | 93 ------ src/{ => utility}/clear.ts | 9 +- src/utility/index.ts | 4 + src/{ => utility}/selectOptions.ts | 38 +-- src/utility/type.ts | 45 +++ src/{ => utility}/upload.ts | 19 +- src/utils/index.ts | 1 + src/utils/misc/getDocumentFromNode.ts | 7 + tests/clear.ts | 50 +-- tests/click/{click.js => click.ts} | 197 +++++------ tests/click/{dblclick.js => dblclick.ts} | 101 ++---- tests/click/tripleClick.ts | 8 +- tests/clipboard/copy.ts | 30 +- tests/clipboard/cut.ts | 30 +- tests/clipboard/{paste.js => paste.ts} | 48 +-- tests/document/index.ts | 10 +- tests/hover/{hover.js => hover.ts} | 42 +-- tests/hover/{unhover.js => unhover.ts} | 22 +- tests/keyboard/getNextKeyDef.ts | 17 +- tests/keyboard/index.ts | 47 +-- tests/keyboard/keyboardImplementation.ts | 39 ++- tests/keyboard/plugin/arrow.ts | 16 +- tests/keyboard/plugin/character.ts | 12 +- tests/keyboard/plugin/combination.ts | 4 +- tests/keyboard/plugin/control.ts | 24 +- tests/keyboard/plugin/functional.ts | 46 +-- tests/keyboard/shared/fireInputEvent.ts | 10 +- tests/pointer/index.ts | 207 ++++++------ tests/react/keyboard.tsx | 10 +- tests/react/type.tsx | 18 +- .../{deselect.js => deselect.ts} | 32 +- tests/selectOptions/{select.js => select.ts} | 74 ++--- tests/setup.ts | 183 ++++------- tests/{tab.js => tab.ts} | 204 ++++++------ tests/type/{index.js => index.ts} | 309 +++++++++--------- tests/type/{modifiers.js => modifiers.ts} | 203 ++++++------ tests/{upload.js => upload.ts} | 117 +++---- tests/utils/dataTransfer/Clipboard.ts | 2 +- tests/utils/dataTransfer/DataTransfer.ts | 14 +- tests/utils/edit/calculateNewValue.ts | 20 +- tests/utils/edit/isContentEditable.ts | 2 +- tests/utils/focus/{blur.js => blur.ts} | 10 +- tests/utils/focus/{focus.js => focus.ts} | 12 +- tests/utils/focus/selectAll.ts | 8 +- tests/utils/focus/selection.ts | 18 +- tests/utils/misc/getDocumentFromNode.ts | 6 + tests/utils/misc/hasPointerEvents.ts | 2 +- tests/utils/misc/isDescendantOrSelf.ts | 2 +- tests/utils/misc/isElementType.js | 4 +- tests/utils/misc/isVisible.js | 2 +- 84 files changed, 1729 insertions(+), 2076 deletions(-) delete mode 100644 src/.eslintrc delete mode 100644 src/click.ts create mode 100644 src/convenience/click.ts create mode 100644 src/convenience/hover.ts create mode 100644 src/convenience/index.ts create mode 100644 src/convenience/tab.ts delete mode 100644 src/hover.ts create mode 100644 src/options.ts delete mode 100644 src/setup.ts create mode 100644 src/setup/api.ts create mode 100644 src/setup/config.ts create mode 100644 src/setup/directApi.ts create mode 100644 src/setup/index.ts create mode 100644 src/setup/setup.ts create mode 100644 src/setup/wrapAsync.ts delete mode 100644 src/tab.ts delete mode 100644 src/type.ts rename src/{ => utility}/clear.ts (74%) create mode 100644 src/utility/index.ts rename src/{ => utility}/selectOptions.ts (81%) create mode 100644 src/utility/type.ts rename src/{ => utility}/upload.ts (86%) create mode 100644 src/utils/misc/getDocumentFromNode.ts rename tests/click/{click.js => click.ts} (71%) rename tests/click/{dblclick.js => dblclick.ts} (66%) rename tests/clipboard/{paste.js => paste.ts} (72%) rename tests/hover/{hover.js => hover.ts} (78%) rename tests/hover/{unhover.js => unhover.ts} (77%) rename tests/selectOptions/{deselect.js => deselect.ts} (80%) rename tests/selectOptions/{select.js => select.ts} (88%) rename tests/{tab.js => tab.ts} (71%) rename tests/type/{index.js => index.ts} (85%) rename tests/type/{modifiers.js => modifiers.ts} (86%) rename tests/{upload.js => upload.ts} (66%) rename tests/utils/focus/{blur.js => blur.ts} (83%) rename tests/utils/focus/{focus.js => focus.ts} (81%) create mode 100644 tests/utils/misc/getDocumentFromNode.ts diff --git a/.eslintrc.js b/.eslintrc.js index 0293c1ce..f9cf0de7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,6 +7,7 @@ module.exports = { }, }, rules: { + 'no-await-in-loop': 0, 'testing-library/no-dom-import': 0, '@typescript-eslint/non-nullable-type-assertion-style': 0, }, diff --git a/src/.eslintrc b/src/.eslintrc deleted file mode 100644 index b7d78581..00000000 --- a/src/.eslintrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "rules": { - // everything in this directory is intentionally running in series, not parallel - // because user's cannot fire multiple events at the same time and we need - // all events fired in a predictable order. - "no-await-in-loop": "off" - } -} diff --git a/src/click.ts b/src/click.ts deleted file mode 100644 index 11a3bfeb..00000000 --- a/src/click.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {hasPointerEvents, PointerOptions} from './utils' -import type {UserEvent} from './setup' - -export declare interface clickOptions { - skipHover?: boolean - clickCount?: number -} - -export function click( - this: UserEvent, - element: Element, - { - skipHover = false, - skipPointerEventsCheck = false, - }: clickOptions & PointerOptions = {}, -) { - if (!skipPointerEventsCheck && !hasPointerEvents(element)) { - throw new Error( - 'unable to click element as it has or inherits pointer-events set to "none".', - ) - } - // istanbul ignore else - if (!skipHover) - // We just checked for `pointerEvents`. We can always skip this one in `hover`. - this.hover(element, {skipPointerEventsCheck: true}) - - this.pointer({keys: '[MouseLeft]', target: element}) -} - -export function dblClick( - this: UserEvent, - element: Element, - {skipPointerEventsCheck = false}: clickOptions & PointerOptions = {}, -) { - if (!skipPointerEventsCheck && !hasPointerEvents(element)) { - throw new Error( - 'unable to double-click element as it has or inherits pointer-events set to "none".', - ) - } - this.hover(element, {skipPointerEventsCheck: true}) - - this.pointer({keys: '[MouseLeft][MouseLeft]', target: element}) -} - -export function tripleClick( - this: UserEvent, - element: Element, - {skipPointerEventsCheck = false}: clickOptions & PointerOptions = {}, -) { - if (!skipPointerEventsCheck && !hasPointerEvents(element)) { - throw new Error( - 'unable to triple-click element as it has or inherits pointer-events set to "none".', - ) - } - this.hover(element, {skipPointerEventsCheck: true}) - - this.pointer({keys: '[MouseLeft][MouseLeft][MouseLeft]', target: element}) -} diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index 671ce031..d909e913 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,24 +1,9 @@ import {fireEvent} from '@testing-library/dom' -import type {UserEvent} from '../setup' +import {Config, UserEvent} from '../setup' import {copySelection, writeDataTransferToClipboard} from '../utils' -export interface copyOptions { - document?: Document - writeToClipboard?: boolean -} - -export function copy( - this: UserEvent, - options: Omit & {writeToClipboard: true}, -): Promise -export function copy( - this: UserEvent, - options?: Omit & { - writeToClipboard?: boolean - }, -): DataTransfer -export function copy(this: UserEvent, options?: copyOptions) { - const doc = options?.document ?? document +export async function copy(this: UserEvent) { + const doc = this[Config].document const target = doc.activeElement ?? /* istanbul ignore next */ doc.body const clipboardData = copySelection(target) @@ -31,7 +16,9 @@ export function copy(this: UserEvent, options?: copyOptions) { clipboardData, }) - return options?.writeToClipboard - ? writeDataTransferToClipboard(doc, clipboardData).then(() => clipboardData) - : clipboardData + if (this[Config].writeToClipboard) { + await writeDataTransferToClipboard(doc, clipboardData) + } + + return clipboardData } diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index 70bd6f1d..e90e566a 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -1,5 +1,5 @@ import {fireEvent} from '@testing-library/dom' -import type {UserEvent} from '../setup' +import {Config, UserEvent} from '../setup' import { copySelection, isEditable, @@ -7,21 +7,8 @@ import { writeDataTransferToClipboard, } from '../utils' -export interface cutOptions { - document?: Document - writeToClipboard?: boolean -} - -export function cut( - this: UserEvent, - options: Omit & {writeToClipboard: true}, -): Promise -export function cut( - this: UserEvent, - options?: Omit & {writeToClipboard?: boolean}, -): DataTransfer -export function cut(this: UserEvent, options?: cutOptions) { - const doc = options?.document ?? document +export async function cut(this: UserEvent) { + const doc = this[Config].document const target = doc.activeElement ?? /* istanbul ignore next */ doc.body const clipboardData = copySelection(target) @@ -38,7 +25,9 @@ export function cut(this: UserEvent, options?: cutOptions) { prepareInput('', target, 'deleteByCut')?.commit() } - return options?.writeToClipboard - ? writeDataTransferToClipboard(doc, clipboardData).then(() => clipboardData) - : clipboardData + if (this[Config].writeToClipboard) { + await writeDataTransferToClipboard(doc, clipboardData) + } + + return clipboardData } diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 15429449..2ce4499c 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -1,5 +1,5 @@ import {fireEvent} from '@testing-library/dom' -import type {UserEvent} from '../setup' +import {Config, UserEvent} from '../setup' import { createDataTransfer, getSpaceUntilMaxLength, @@ -8,43 +8,24 @@ import { readDataTransferFromClipboard, } from '../utils' -export interface pasteOptions { - document?: Document -} - -export function paste( - this: UserEvent, - clipboardData?: undefined, - options?: pasteOptions, -): Promise -export function paste( - this: UserEvent, - clipboardData: DataTransfer | string, - options?: pasteOptions, -): void -export function paste( +export async function paste( this: UserEvent, clipboardData?: DataTransfer | string, - options?: pasteOptions, ) { - const doc = options?.document ?? document + const doc = this[Config].document const target = doc.activeElement ?? /* istanbul ignore next */ doc.body - const data: DataTransfer | undefined = - typeof clipboardData === 'string' + const data: DataTransfer = + (typeof clipboardData === 'string' ? getClipboardDataFromString(clipboardData) - : clipboardData - - return data - ? pasteImpl(target, data) - : readDataTransferFromClipboard(doc).then( - dt => pasteImpl(target, dt), - () => { - throw new Error( - '`userEvent.paste()` without `clipboardData` requires the `ClipboardAPI` to be available.', - ) - }, + : clipboardData) ?? + (await readDataTransferFromClipboard(doc).catch(() => { + throw new Error( + '`userEvent.paste()` without `clipboardData` requires the `ClipboardAPI` to be available.', ) + })) + + return pasteImpl(target, data) } function pasteImpl(target: Element, clipboardData: DataTransfer) { diff --git a/src/convenience/click.ts b/src/convenience/click.ts new file mode 100644 index 00000000..21ed37e7 --- /dev/null +++ b/src/convenience/click.ts @@ -0,0 +1,45 @@ +import type {PointerInput} from '../pointer' +import {hasPointerEvents} from '../utils' +import {Config, UserEvent} from '../setup' + +export async function click(this: UserEvent, element: Element): Promise { + if (!this[Config].skipPointerEventsCheck && !hasPointerEvents(element)) { + throw new Error( + 'unable to click element as it has or inherits pointer-events set to "none".', + ) + } + + const pointerIn: PointerInput = [] + if (!this[Config].skipHover) { + pointerIn.push({target: element}) + } + pointerIn.push({keys: '[MouseLeft]', target: element}) + + return this.pointer(pointerIn) +} + +export async function dblClick( + this: UserEvent, + element: Element, +): Promise { + if (!this[Config].skipPointerEventsCheck && !hasPointerEvents(element)) { + throw new Error( + 'unable to double-click element as it has or inherits pointer-events set to "none".', + ) + } + + return this.pointer([{target: element}, '[MouseLeft][MouseLeft]']) +} + +export async function tripleClick( + this: UserEvent, + element: Element, +): Promise { + if (!this[Config].skipPointerEventsCheck && !hasPointerEvents(element)) { + throw new Error( + 'unable to triple-click element as it has or inherits pointer-events set to "none".', + ) + } + + return this.pointer([{target: element}, '[MouseLeft][MouseLeft][MouseLeft]']) +} diff --git a/src/convenience/hover.ts b/src/convenience/hover.ts new file mode 100644 index 00000000..dd0fc573 --- /dev/null +++ b/src/convenience/hover.ts @@ -0,0 +1,22 @@ +import {Config, UserEvent} from '../setup' +import {hasPointerEvents} from '../utils' + +export async function hover(this: UserEvent, element: Element) { + if (!this[Config].skipPointerEventsCheck && !hasPointerEvents(element)) { + throw new Error( + 'unable to hover element as it has or inherits pointer-events set to "none".', + ) + } + + return this.pointer({target: element}) +} + +export async function unhover(this: UserEvent, element: Element) { + if (!this[Config].skipPointerEventsCheck && !hasPointerEvents(element)) { + throw new Error( + 'unable to unhover element as it has or inherits pointer-events set to "none".', + ) + } + + return this.pointer({target: element.ownerDocument.body}) +} diff --git a/src/convenience/index.ts b/src/convenience/index.ts new file mode 100644 index 00000000..d29d059f --- /dev/null +++ b/src/convenience/index.ts @@ -0,0 +1,3 @@ +export * from './click' +export * from './hover' +export * from './tab' diff --git a/src/convenience/tab.ts b/src/convenience/tab.ts new file mode 100644 index 00000000..060600b7 --- /dev/null +++ b/src/convenience/tab.ts @@ -0,0 +1,18 @@ +import type {UserEvent} from '../setup' + +export async function tab( + this: UserEvent, + { + shift, + }: { + shift?: boolean + } = {}, +) { + return this.keyboard( + shift === true + ? '{Shift>}{Tab}{/Shift}' + : shift === false + ? '[/ShiftLeft][/ShiftRight]{Tab}' + : '{Tab}', + ) +} diff --git a/src/hover.ts b/src/hover.ts deleted file mode 100644 index b48b230f..00000000 --- a/src/hover.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {createPointerState} from './pointer' -import type {UserEvent} from './setup' -import {hasPointerEvents, PointerOptions} from './utils' - -export function hover( - this: UserEvent, - element: Element, - {skipPointerEventsCheck = false}: PointerOptions = {}, -) { - if (!skipPointerEventsCheck && !hasPointerEvents(element)) { - throw new Error( - 'unable to hover element as it has or inherits pointer-events set to "none".', - ) - } - - const pointerState = createPointerState() - pointerState.position.mouse.target = element.ownerDocument.body - - this.pointer({target: element}, {pointerState}) -} - -export function unhover( - this: UserEvent, - element: Element, - {skipPointerEventsCheck = false}: PointerOptions = {}, -) { - if (!skipPointerEventsCheck && !hasPointerEvents(element)) { - throw new Error( - 'unable to unhover element as it has or inherits pointer-events set to "none".', - ) - } - - const pointerState = createPointerState() - pointerState.position.mouse.target = element - - this.pointer({target: element.ownerDocument.body}, {pointerState}) -} diff --git a/src/index.ts b/src/index.ts index 70796a42..37ac21bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,3 @@ -import {userEventApis, UserEventApis, setup, UserEvent} from './setup' - -const userEvent: UserEventApis & { - setup: typeof setup -} = { - ...(Object.fromEntries( - Object.entries(userEventApis).map(([k, f]) => [ - k, - (...a: unknown[]) => - (f as (this: UserEvent, ...b: unknown[]) => unknown).apply( - userEvent, - a, - ), - ]), - ) as UserEventApis), - setup, -} - -export default userEvent - +export {userEvent as default} from './setup' export type {keyboardKey} from './keyboard' +export type {pointerKey} from './pointer' diff --git a/src/keyboard/getNextKeyDef.ts b/src/keyboard/getNextKeyDef.ts index 4ef37e71..13064d13 100644 --- a/src/keyboard/getNextKeyDef.ts +++ b/src/keyboard/getNextKeyDef.ts @@ -1,5 +1,5 @@ import {readNextDescriptor} from '../utils' -import {keyboardKey, keyboardOptions} from './types' +import {keyboardKey} from './types' /** * Get the next key from keyMap @@ -12,8 +12,8 @@ import {keyboardKey, keyboardOptions} from './types' * You can then release the key per `{key>3/}` or keep it pressed and continue with the next key. */ export function getNextKeyDef( + keyboardMap: keyboardKey[], text: string, - options: keyboardOptions, ): { keyDef: keyboardKey consumedLength: number @@ -30,7 +30,7 @@ export function getNextKeyDef( repeat, } = readNextDescriptor(text) - const keyDef = options.keyboardMap.find(def => { + const keyDef = keyboardMap.find(def => { if (type === '[') { return def.code?.toLowerCase() === descriptor.toLowerCase() } else if (type === '{') { diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index 4dab781e..94868e64 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -1,64 +1,12 @@ -import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' -import {prepareDocument} from '../document' +import {Config, UserEvent} from '../setup' import {keyboardImplementation, releaseAllKeys} from './keyboardImplementation' -import {defaultKeyMap} from './keyMap' -import {keyboardState, keyboardOptions, keyboardKey} from './types' +import type {keyboardState, keyboardKey} from './types' -export type {keyboardOptions, keyboardKey} +export {releaseAllKeys} +export type {keyboardKey, keyboardState} -export function keyboard( - text: string, - options?: Partial, -): keyboardState -export function keyboard( - text: string, - options: Partial< - keyboardOptions & {keyboardState: keyboardState; delay: number} - >, -): Promise -export function keyboard( - text: string, - options?: Partial, -): keyboardState | Promise { - const {promise, state} = keyboardImplementationWrapper(text, options) - - if ((options?.delay ?? 0) > 0) { - return getDOMTestingLibraryConfig().asyncWrapper(() => - promise.then(() => state), - ) - } else { - // prevent users from dealing with UnhandledPromiseRejectionWarning in sync call - promise.catch(console.error) - - return state - } -} - -export function keyboardImplementationWrapper( - text: string, - config: Partial = {}, -) { - const { - keyboardState: state = createKeyboardState(), - delay = 0, - document: doc = document, - autoModify = false, - keyboardMap = defaultKeyMap, - } = config - const options = { - delay, - document: doc, - autoModify, - keyboardMap, - } - - prepareDocument(document) - - return { - promise: keyboardImplementation(text, options, state), - state, - releaseAllKeys: () => releaseAllKeys(options, state), - } +export async function keyboard(this: UserEvent, text: string): Promise { + return keyboardImplementation(this[Config], text) } export function createKeyboardState(): keyboardState { diff --git a/src/keyboard/keyboardImplementation.ts b/src/keyboard/keyboardImplementation.ts index 7fe3b935..aee41bf3 100644 --- a/src/keyboard/keyboardImplementation.ts +++ b/src/keyboard/keyboardImplementation.ts @@ -1,60 +1,44 @@ import {fireEvent} from '@testing-library/dom' +import {Config} from '../setup' import {getActiveElement, wait} from '../utils' import {getNextKeyDef} from './getNextKeyDef' -import { - behaviorPlugin, - keyboardKey, - keyboardState, - keyboardOptions, -} from './types' +import {behaviorPlugin, keyboardKey} from './types' import * as plugins from './plugins' import {getKeyEventProps} from './getEventProps' export async function keyboardImplementation( + config: Config, text: string, - options: keyboardOptions, - state: keyboardState, ): Promise { - const {document} = options + const {document, keyboardState, keyboardMap, delay} = config const getCurrentElement = () => getActive(document) const {keyDef, consumedLength, releasePrevious, releaseSelf, repeat} = - state.repeatKey ?? getNextKeyDef(text, options) + keyboardState.repeatKey ?? getNextKeyDef(keyboardMap, text) - const pressed = state.pressed.find(p => p.keyDef === keyDef) + const pressed = keyboardState.pressed.find(p => p.keyDef === keyDef) // Release the key automatically if it was pressed before. // Do not release the key on iterations on `state.repeatKey`. - if (pressed && !state.repeatKey) { - keyup( - keyDef, - getCurrentElement, - options, - state, - pressed.unpreventedDefault, - ) + if (pressed && !keyboardState.repeatKey) { + await keyup(keyDef, getCurrentElement, config, pressed.unpreventedDefault) } if (!releasePrevious) { - const unpreventedDefault = keydown( - keyDef, - getCurrentElement, - options, - state, - ) + const unpreventedDefault = await keydown(keyDef, getCurrentElement, config) - if (unpreventedDefault && hasKeyPress(keyDef, state)) { - keypress(keyDef, getCurrentElement, options, state) + if (unpreventedDefault && hasKeyPress(keyDef, config)) { + await keypress(keyDef, getCurrentElement, config) } // Release the key only on the last iteration on `state.repeatKey`. if (releaseSelf && repeat <= 1) { - keyup(keyDef, getCurrentElement, options, state, unpreventedDefault) + await keyup(keyDef, getCurrentElement, config, unpreventedDefault) } } if (repeat > 1) { - state.repeatKey = { + keyboardState.repeatKey = { // don't consume again on the next iteration consumedLength: 0, keyDef, @@ -63,15 +47,15 @@ export async function keyboardImplementation( repeat: repeat - 1, } } else { - delete state.repeatKey + delete keyboardState.repeatKey } if (text.length > consumedLength || repeat > 1) { - if (options.delay > 0) { - await wait(options.delay) + if (typeof delay === 'number') { + await wait(delay) } - return keyboardImplementation(text.slice(consumedLength), options, state) + return keyboardImplementation(config, text.slice(consumedLength)) } return void undefined } @@ -80,128 +64,106 @@ function getActive(document: Document): Element { return getActiveElement(document) ?? /* istanbul ignore next */ document.body } -export function releaseAllKeys(options: keyboardOptions, state: keyboardState) { - const getCurrentElement = () => getActive(options.document) - for (const k of state.pressed) { - keyup(k.keyDef, getCurrentElement, options, state, k.unpreventedDefault) +export async function releaseAllKeys(config: Config) { + const getCurrentElement = () => getActive(config.document) + for (const k of config.keyboardState.pressed) { + await keyup(k.keyDef, getCurrentElement, config, k.unpreventedDefault) } } -function keydown( +async function keydown( keyDef: keyboardKey, getCurrentElement: () => Element, - options: keyboardOptions, - state: keyboardState, + config: Config, ) { const element = getCurrentElement() // clear carried characters when focus is moved - if (element !== state.activeElement) { - state.carryValue = undefined - state.carryChar = '' + if (element !== config.keyboardState.activeElement) { + config.keyboardState.carryValue = undefined + config.keyboardState.carryChar = '' } - state.activeElement = element + config.keyboardState.activeElement = element - applyPlugins(plugins.preKeydownBehavior, keyDef, element, options, state) + applyPlugins(plugins.preKeydownBehavior, keyDef, element, config) const unpreventedDefault = fireEvent.keyDown( element, - getKeyEventProps(keyDef, state), + getKeyEventProps(keyDef, config.keyboardState), ) - state.pressed.push({keyDef, unpreventedDefault}) + config.keyboardState.pressed.push({keyDef, unpreventedDefault}) if (unpreventedDefault) { // all default behavior like keypress/submit etc is applied to the currentElement - applyPlugins( - plugins.keydownBehavior, - keyDef, - getCurrentElement(), - options, - state, - ) + applyPlugins(plugins.keydownBehavior, keyDef, getCurrentElement(), config) } return unpreventedDefault } -function keypress( +async function keypress( keyDef: keyboardKey, getCurrentElement: () => Element, - options: keyboardOptions, - state: keyboardState, + config: Config, ) { const element = getCurrentElement() const unpreventedDefault = fireEvent.keyPress(element, { - ...getKeyEventProps(keyDef, state), + ...getKeyEventProps(keyDef, config.keyboardState), charCode: keyDef.key === 'Enter' ? 13 : String(keyDef.key).charCodeAt(0), }) if (unpreventedDefault) { - applyPlugins( - plugins.keypressBehavior, - keyDef, - getCurrentElement(), - options, - state, - ) + applyPlugins(plugins.keypressBehavior, keyDef, getCurrentElement(), config) } } -function keyup( +async function keyup( keyDef: keyboardKey, getCurrentElement: () => Element, - options: keyboardOptions, - state: keyboardState, + config: Config, unprevented: boolean, ) { const element = getCurrentElement() - applyPlugins(plugins.preKeyupBehavior, keyDef, element, options, state) + applyPlugins(plugins.preKeyupBehavior, keyDef, element, config) const unpreventedDefault = fireEvent.keyUp( element, - getKeyEventProps(keyDef, state), + getKeyEventProps(keyDef, config.keyboardState), ) if (unprevented && unpreventedDefault) { - applyPlugins( - plugins.keyupBehavior, - keyDef, - getCurrentElement(), - options, - state, - ) + applyPlugins(plugins.keyupBehavior, keyDef, getCurrentElement(), config) } - state.pressed = state.pressed.filter(k => k.keyDef !== keyDef) + config.keyboardState.pressed = config.keyboardState.pressed.filter( + k => k.keyDef !== keyDef, + ) - applyPlugins(plugins.postKeyupBehavior, keyDef, element, options, state) + applyPlugins(plugins.postKeyupBehavior, keyDef, element, config) } function applyPlugins( pluginCollection: behaviorPlugin[], keyDef: keyboardKey, element: Element, - options: keyboardOptions, - state: keyboardState, + config: Config, ): boolean { - const plugin = pluginCollection.find(p => - p.matches(keyDef, element, options, state), - ) + const plugin = pluginCollection.find(p => p.matches(keyDef, element, config)) if (plugin) { - plugin.handle(keyDef, element, options, state) + plugin.handle(keyDef, element, config) } return !!plugin } -function hasKeyPress(keyDef: keyboardKey, state: keyboardState) { +function hasKeyPress(keyDef: keyboardKey, config: Config) { return ( (keyDef.key?.length === 1 || keyDef.key === 'Enter') && - !state.modifiers.ctrl && - !state.modifiers.alt + !config.keyboardState.modifiers.ctrl && + !config.keyboardState.modifiers.alt ) } diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts index fbc67eff..62dfb146 100644 --- a/src/keyboard/plugins/character.ts +++ b/src/keyboard/plugins/character.ts @@ -26,10 +26,10 @@ export const keypressBehavior: behaviorPlugin[] = [ matches: (keyDef, element) => keyDef.key?.length === 1 && isElementType(element, 'input', {type: 'time', readOnly: false}), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element, {keyboardState}) => { let newEntry = keyDef.key as string - const textToBeTyped = (state.carryValue ?? '') + newEntry + const textToBeTyped = (keyboardState.carryValue ?? '') + newEntry const timeNewEntry = buildTimeValue(textToBeTyped) if ( isValidInputTimeValue( @@ -69,17 +69,17 @@ export const keypressBehavior: behaviorPlugin[] = [ timeNewEntry, ) - state.carryValue = textToBeTyped + keyboardState.carryValue = textToBeTyped }, }, { matches: (keyDef, element) => keyDef.key?.length === 1 && isElementType(element, 'input', {type: 'date', readOnly: false}), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element, {keyboardState}) => { let newEntry = keyDef.key as string - const textToBeTyped = (state.carryValue ?? '') + newEntry + const textToBeTyped = (keyboardState.carryValue ?? '') + newEntry const isValidToBeTyped = isValidDateValue( element as HTMLInputElement & {type: 'date'}, textToBeTyped, @@ -117,7 +117,7 @@ export const keypressBehavior: behaviorPlugin[] = [ }) } - state.carryValue = textToBeTyped + keyboardState.carryValue = textToBeTyped }, }, { @@ -167,11 +167,11 @@ export const keypressBehavior: behaviorPlugin[] = [ (isElementType(element, 'textarea', {readOnly: false}) || isContentEditable(element)) && getSpaceUntilMaxLength(element) !== 0, - handle: (keyDef, element, options, state) => { + handle: (keyDef, element, {keyboardState}) => { prepareInput( '\n', element, - isContentEditable(element) && !state.modifiers.shift + isContentEditable(element) && !keyboardState.modifiers.shift ? 'insertParagraph' : 'insertLineBreak', )?.commit() diff --git a/src/keyboard/plugins/combination.ts b/src/keyboard/plugins/combination.ts index 566741a1..eecdae8e 100644 --- a/src/keyboard/plugins/combination.ts +++ b/src/keyboard/plugins/combination.ts @@ -2,15 +2,13 @@ * Default behavior for key combinations */ -import { behaviorPlugin } from '../types' -import { - selectAll, -} from '../../utils' +import {behaviorPlugin} from '../types' +import {selectAll} from '../../utils' export const keydownBehavior: behaviorPlugin[] = [ - { - matches: (keyDef, element, options, state) => - keyDef.code === 'KeyA' && state.modifiers.ctrl, - handle: (keyDef, element) => selectAll(element) - }, + { + matches: (keyDef, element, {keyboardState}) => + keyDef.code === 'KeyA' && keyboardState.modifiers.ctrl, + handle: (keyDef, element) => selectAll(element), + }, ] diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts index dd69f322..ba592dfc 100644 --- a/src/keyboard/plugins/functional.ts +++ b/src/keyboard/plugins/functional.ts @@ -30,8 +30,8 @@ export const preKeydownBehavior: behaviorPlugin[] = [ ...Object.entries(modifierKeys).map( ([key, modKey]): behaviorPlugin => ({ matches: keyDef => keyDef.key === key, - handle: (keyDef, element, options, state) => { - state.modifiers[modKey] = true + handle: (keyDef, element, {keyboardState}) => { + keyboardState.modifiers[modKey] = true }, }), ), @@ -40,11 +40,11 @@ export const preKeydownBehavior: behaviorPlugin[] = [ // The modifier does not change { matches: keyDef => keyDef.key === 'AltGraph', - handle: (keyDef, element, options, state) => { - const ctrlKeyDef = options.keyboardMap.find( + handle: (keyDef, element, {keyboardMap, keyboardState}) => { + const ctrlKeyDef = keyboardMap.find( k => k.key === 'Control', ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - fireEvent.keyDown(element, getKeyEventProps(ctrlKeyDef, state)) + fireEvent.keyDown(element, getKeyEventProps(ctrlKeyDef, keyboardState)) }, }, ] @@ -52,8 +52,8 @@ export const preKeydownBehavior: behaviorPlugin[] = [ export const keydownBehavior: behaviorPlugin[] = [ { matches: keyDef => keyDef.key === 'CapsLock', - handle: (keyDef, element, options, state) => { - state.modifiers.caps = !state.modifiers.caps + handle: (keyDef, element, {keyboardState}) => { + keyboardState.modifiers.caps = !keyboardState.modifiers.caps }, }, { @@ -65,8 +65,8 @@ export const keydownBehavior: behaviorPlugin[] = [ }, { matches: keyDef => keyDef.key === 'Tab', - handle: (keyDef, element, options, state) => { - const dest = getTabDestination(element, state.modifiers.shift) + handle: (keyDef, element, {keyboardState}) => { + const dest = getTabDestination(element, keyboardState.modifiers.shift) if (dest === element.ownerDocument.body) { blur(element) } else { @@ -102,8 +102,8 @@ export const keypressBehavior: behaviorPlugin[] = [ (isClickableInput(element) || // Links with href defined should handle Enter the same as a click (isElementType(element, 'a') && Boolean(element.href))), - handle: (keyDef, element, options, state) => { - fireEvent.click(element, getMouseEventProps(state)) + handle: (keyDef, element, {keyboardState}) => { + fireEvent.click(element, getMouseEventProps(keyboardState)) }, }, { @@ -127,8 +127,8 @@ export const preKeyupBehavior: behaviorPlugin[] = [ ...Object.entries(modifierKeys).map( ([key, modKey]): behaviorPlugin => ({ matches: keyDef => keyDef.key === key, - handle: (keyDef, element, options, state) => { - state.modifiers[modKey] = false + handle: (keyDef, element, {keyboardState}) => { + keyboardState.modifiers[modKey] = false }, }), ), @@ -138,8 +138,8 @@ export const keyupBehavior: behaviorPlugin[] = [ { matches: (keyDef, element) => keyDef.key === ' ' && isClickableInput(element), - handle: (keyDef, element, options, state) => { - fireEvent.click(element, getMouseEventProps(state)) + handle: (keyDef, element, {keyboardState}) => { + fireEvent.click(element, getMouseEventProps(keyboardState)) }, }, ] @@ -149,11 +149,11 @@ export const postKeyupBehavior: behaviorPlugin[] = [ // The modifier does not change { matches: keyDef => keyDef.key === 'AltGraph', - handle: (keyDef, element, options, state) => { - const ctrlKeyDef = options.keyboardMap.find( + handle: (keyDef, element, {keyboardMap, keyboardState}) => { + const ctrlKeyDef = keyboardMap.find( k => k.key === 'Control', ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - fireEvent.keyUp(element, getKeyEventProps(ctrlKeyDef, state)) + fireEvent.keyUp(element, getKeyEventProps(ctrlKeyDef, keyboardState)) }, }, ] diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts index 3b7a683c..d7f2031a 100644 --- a/src/keyboard/types.ts +++ b/src/keyboard/types.ts @@ -1,3 +1,4 @@ +import {Config} from '../setup' import {getNextKeyDef} from './getNextKeyDef' /** @@ -48,17 +49,6 @@ export type keyboardState = { repeatKey?: ReturnType } -export type keyboardOptions = { - /** Document in which to perform the events */ - document: Document - /** Delay between keystrokes */ - delay: number - /** Add modifiers for given keys - not implemented yet */ - autoModify: boolean - /** Keyboard layout to use */ - keyboardMap: keyboardKey[] -} - export enum DOM_KEY_LOCATION { STANDARD = 0, LEFT = 1, @@ -80,16 +70,6 @@ export interface keyboardKey { } export interface behaviorPlugin { - matches: ( - keyDef: keyboardKey, - element: Element, - options: keyboardOptions, - state: keyboardState, - ) => boolean - handle: ( - keyDef: keyboardKey, - element: Element, - options: keyboardOptions, - state: keyboardState, - ) => void + matches: (keyDef: keyboardKey, element: Element, config: Config) => boolean + handle: (keyDef: keyboardKey, element: Element, config: Config) => void } diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 00000000..e8c15f94 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,132 @@ +import type {keyboardKey} from './keyboard/types' +import type {pointerKey} from './pointer/types' +import {defaultKeyMap as defaultKeyboardMap} from './keyboard/keyMap' +import {defaultKeyMap as defaultPointerMap} from './pointer/keyMap' + +export interface Options { + /** + * When using `userEvent.upload`, automatically discard files + * that don't match an `accept` property if it exists. + * + * @default true + */ + applyAccept?: boolean + + /** + * We intend to automatically apply modifier keys for printable characters in the future. + * I.e. `A` implying `{Shift>}a{/Shift}` if caps lock is not active. + * + * This options allows you to opt out of this change in foresight. + * The feature therefore will not constitute a breaking change. + * + * @default true + */ + autoModify?: boolean + + /** + * Between some subsequent inputs like typing a series of characters + * the code execution is delayed per `setTimeout` for (at least) `delay` seconds. + * This moves the next changes at least to next macro task + * and allows other (asynchronous) code to run between events. + * + * `null` prevents `setTimeout` from being called. + * + * @default 0 + */ + delay?: number | null + + /** + * The document. + * + * This defaults to the owner document of an element if an API is called directly with an element and without setup. + * Otherwise it falls back to the global document. + * + * @default element.ownerDocument??global.document + */ + document?: Document + + /** + * An array of keyboard keys the keyboard device consists of. + * + * This allows to plug in different layouts / localizations. + * + * Defaults to a "standard" US-104-QWERTY keyboard. + */ + keyboardMap?: keyboardKey[] + + /** + * An array of available pointer keys. + * + * This allows to plug in different pointer devices. + */ + pointerMap?: pointerKey[] + + /** + * `userEvent.type` automatically releases any keys still pressed at the end of the call. + * This option allows to opt out of this feature. + * + * @default false + */ + skipAutoClose?: boolean + + /** + * `userEvent.type` implys a click at the end of the element content/value. + * This option allows to opt out of this feature. + * + * @default false + */ + skipClick?: boolean + + /** + * `userEvent.click` implys moving the cursor to the target element first. + * This options allows to opt out of this feature. + * + * @default false + */ + skipHover?: boolean + + /** + * Calling pointer related APIs on an element triggers a check if that element can receive pointer events. + * This check is known to be expensive. + * This option allows to skip the check. + * + * @default false + */ + skipPointerEventsCheck?: boolean + + /** + * Write selected data to Clipboard API when a `cut` or `copy` is triggered. + * + * The Clipboard API is usually not available to test code. + * Our `setup` replaces the `navigator.clipboard` property with a stub. + * + * Defaults to `false` when calling the APIs directly. + * Defaults to `true` when calling the APIs per `setup`. + */ + writeToClipboard?: boolean +} + +/** + * Default options applied when API is called per `userEvent.anyApi()` + */ +export const defaultOptionsDirect: Required = { + applyAccept: true, + autoModify: true, + delay: 0, + document: global.document, + keyboardMap: defaultKeyboardMap, + pointerMap: defaultPointerMap, + skipAutoClose: false, + skipClick: false, + skipHover: false, + skipPointerEventsCheck: false, + writeToClipboard: false, +} + +/** + * Default options applied when API is called per `userEvent().anyApi()` + */ +export const defaultOptionsSetup: Required = { + ...defaultOptionsDirect, + writeToClipboard: true, +} diff --git a/src/pointer/index.ts b/src/pointer/index.ts index 75da2eeb..2f399f31 100644 --- a/src/pointer/index.ts +++ b/src/pointer/index.ts @@ -1,68 +1,33 @@ -import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' -import {createKeyboardState} from '../keyboard' +import {Config, UserEvent} from '../setup' import {parseKeyDef} from './parseKeyDef' -import {defaultKeyMap} from './keyMap' import { pointerAction, PointerAction, PointerActionTarget, } from './pointerAction' -import type {inputDeviceState, pointerOptions, pointerState} from './types' +import type {pointerState, pointerKey} from './types' -export function pointer( - input: PointerInput, - options?: Partial, -): pointerState -export function pointer( - input: PointerInput, - options: Partial, -): Promise -export function pointer( - input: PointerInput, - options: Partial = {}, -) { - const {promise, pointerState} = pointerImplementationWrapper(input, options) - - if ((options.delay ?? 0) > 0) { - return getDOMTestingLibraryConfig().asyncWrapper(() => - promise.then(() => pointerState), - ) - } else { - // prevent users from dealing with UnhandledPromiseRejectionWarning in sync call - promise.catch(console.error) - - return pointerState - } -} +export type {pointerState, pointerKey} type PointerActionInput = | string | ({keys: string} & PointerActionTarget) | PointerAction -type PointerInput = PointerActionInput | Array +export type PointerInput = PointerActionInput | Array -export function pointerImplementationWrapper( +export async function pointer( + this: UserEvent, input: PointerInput, - config: Partial, -) { - const { - pointerState = createPointerState(), - keyboardState = createKeyboardState(), - delay = 0, - pointerMap = defaultKeyMap, - } = config - const options = { - delay, - pointerMap, - } +): Promise { + const {pointerMap} = this[Config] const actions: PointerAction[] = [] ;(Array.isArray(input) ? input : [input]).forEach(actionInput => { if (typeof actionInput === 'string') { - actions.push(...parseKeyDef(actionInput, options)) + actions.push(...parseKeyDef(pointerMap, actionInput)) } else if ('keys' in actionInput) { actions.push( - ...parseKeyDef(actionInput.keys, options).map(i => ({ + ...parseKeyDef(pointerMap, actionInput.keys).map(i => ({ ...actionInput, ...i, })), @@ -72,19 +37,17 @@ export function pointerImplementationWrapper( } }) - return { - promise: pointerAction(actions, options, {pointerState, keyboardState}), - pointerState, - } + return pointerAction(this[Config], actions).then(() => undefined) } -export function createPointerState(): pointerState { +export function createPointerState(document: Document): pointerState { return { pointerId: 1, position: { mouse: { pointerType: 'mouse', pointerId: 1, + target: document.body, coords: { clientX: 0, clientY: 0, diff --git a/src/pointer/parseKeyDef.ts b/src/pointer/parseKeyDef.ts index e2fe70ae..60b22599 100644 --- a/src/pointer/parseKeyDef.ts +++ b/src/pointer/parseKeyDef.ts @@ -1,7 +1,7 @@ import {readNextDescriptor} from '../utils' -import {pointerKey, pointerOptions} from './types' +import {pointerKey} from './types' -export function parseKeyDef(keys: string, {pointerMap}: pointerOptions) { +export function parseKeyDef(pointerMap: pointerKey[], keys: string) { const defs: Array<{ keyDef: pointerKey releasePrevious: boolean diff --git a/src/pointer/pointerAction.ts b/src/pointer/pointerAction.ts index 8b55c0ea..ac1f6a31 100644 --- a/src/pointer/pointerAction.ts +++ b/src/pointer/pointerAction.ts @@ -1,13 +1,8 @@ +import {Config} from '../setup' import {wait} from '../utils' import {pointerMove, PointerMoveAction} from './pointerMove' import {pointerPress, PointerPressAction} from './pointerPress' -import { - inputDeviceState, - pointerOptions, - pointerState, - PointerTarget, - SelectionTarget, -} from './types' +import {pointerState, PointerTarget, SelectionTarget} from './types' export type PointerActionTarget = Partial & Partial @@ -18,13 +13,7 @@ export type PointerAction = PointerActionTarget & | Omit ) -export async function pointerAction( - actions: PointerAction[], - options: pointerOptions, - state: inputDeviceState, -): Promise { - const ret: Array> = [] - +export async function pointerAction(config: Config, actions: PointerAction[]) { for (let i = 0; i < actions.length; i++) { const action = actions[i] const pointerName = @@ -37,31 +26,25 @@ export async function pointerAction( : 'mouse' const target = - action.target ?? getPrevTarget(pointerName, state.pointerState) + action.target ?? getPrevTarget(pointerName, config.pointerState) const coords = action.coords ?? - (pointerName in state.pointerState.position - ? state.pointerState.position[pointerName].coords + (pointerName in config.pointerState.position + ? config.pointerState.position[pointerName].coords : undefined) - const promise = - 'keyDef' in action - ? pointerPress({...action, target, coords}, state) - : pointerMove({...action, target, coords}, state) - - ret.push(promise) + await ('keyDef' in action + ? pointerPress({...action, target, coords}, config) + : pointerMove({...action, target, coords}, config)) - if (options.delay > 0) { - await promise + if (typeof config.delay === 'number') { if (i < actions.length - 1) { - await wait(options.delay) + await wait(config.delay) } } } - delete state.pointerState.activeClickCount - - return Promise.all(ret) + delete config.pointerState.activeClickCount } function getPrevTarget(pointerName: string, state: pointerState) { diff --git a/src/pointer/pointerMove.ts b/src/pointer/pointerMove.ts index aed97ca9..d1b78ffb 100644 --- a/src/pointer/pointerMove.ts +++ b/src/pointer/pointerMove.ts @@ -1,4 +1,5 @@ import {setUISelection} from '../document' +import {inputDeviceState} from '../setup' import { PointerCoords, firePointerEvent, @@ -6,7 +7,7 @@ import { isDisabled, } from '../utils' import {resolveSelectionTarget} from './resolveSelectionTarget' -import {inputDeviceState, PointerTarget, SelectionTarget} from './types' +import {PointerTarget, SelectionTarget} from './types' export interface PointerMoveAction extends PointerTarget, SelectionTarget { pointerName?: string diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index a517464d..a25ad79f 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -10,8 +10,8 @@ import { isFocusable, } from '../utils' import {getUIValue, setUISelection} from '../document' +import {inputDeviceState} from '../setup' import type { - inputDeviceState, pointerKey, pointerState, PointerTarget, diff --git a/src/pointer/types.ts b/src/pointer/types.ts index 10da23e0..babe9f2b 100644 --- a/src/pointer/types.ts +++ b/src/pointer/types.ts @@ -1,4 +1,3 @@ -import {keyboardState} from '../keyboard/types' import {PointerCoords, MouseButton} from '../utils' /** @@ -42,13 +41,6 @@ export type pointerState = { pointerId: number } -export type pointerOptions = { - /** Delay between keystrokes */ - delay: number - /** Available pointer keys */ - pointerMap: pointerKey[] -} - export interface pointerKey { /** Name of the pointer key */ name: string @@ -78,8 +70,3 @@ export interface SelectionInputRange { start: number end: number } - -export interface inputDeviceState { - pointerState: pointerState - keyboardState: keyboardState -} diff --git a/src/setup.ts b/src/setup.ts deleted file mode 100644 index 14c952e8..00000000 --- a/src/setup.ts +++ /dev/null @@ -1,260 +0,0 @@ -import {clear} from './clear' -import {click, clickOptions, dblClick, tripleClick} from './click' -import {prepareDocument} from './document' -import {hover, unhover} from './hover' -import {createKeyboardState, keyboard, keyboardOptions} from './keyboard' -import type {keyboardState} from './keyboard/types' -import { - copy, - copyOptions, - cut, - cutOptions, - paste, - pasteOptions, -} from './clipboard' -import {createPointerState, pointer} from './pointer' -import type {pointerOptions, pointerState} from './pointer/types' -import {deselectOptions, selectOptions} from './selectOptions' -import {tab, tabOptions} from './tab' -import {type, typeOptions} from './type' -import {upload, uploadOptions} from './upload' -import {PointerOptions, attachClipboardStubToView} from './utils' - -export const userEventApis = { - clear, - click, - copy, - cut, - dblClick, - deselectOptions, - hover, - keyboard, - paste, - pointer, - selectOptions, - tab, - tripleClick, - type, - unhover, - upload, -} -export type UserEventApis = typeof userEventApis -type setup = ReturnType['setup'] -export type UserEvent = UserEventApis & { - setup: setup -} - -type ClickOptions = Omit - -interface ClipboardOptions extends copyOptions, cutOptions, pasteOptions {} - -type KeyboardOptions = Partial - -type PointerApiOptions = Partial - -type TabOptions = Omit - -type TypeOptions = Omit< - typeOptions, - 'initialSelectionStart' | 'initialSelectionEnd' -> - -type UploadOptions = uploadOptions - -interface SetupOptions - extends ClickOptions, - ClipboardOptions, - KeyboardOptions, - PointerOptions, - PointerApiOptions, - TabOptions, - TypeOptions, - UploadOptions {} - -/** - * Start a "session" with userEvent. - * All APIs returned by this function share an input device state and a default configuration. - */ -export function setup(options: SetupOptions = {}) { - const doc = options.document ?? document - prepareDocument(doc) - - const view = doc.defaultView ?? /* istanbul ignore next */ window - attachClipboardStubToView(view) - - return _setup(options, { - keyboardState: createKeyboardState(), - pointerState: createPointerState(), - }) -} - -function _setup( - { - applyAccept, - autoModify, - delay = 0, - document, - keyboardMap, - pointerMap, - skipAutoClose, - skipClick, - skipHover, - skipPointerEventsCheck = false, - // Changing default return type from DataTransfer to Promise - // would require a lot of overloading right now. - // The APIs returned by setup will most likely be changed to async before stable release anyway. - // See https://github.com/testing-library/user-event/issues/504#issuecomment-944883855 - // So the default option can be changed during alpha instead of introducing too much code here. - // TODO: This should default to true - writeToClipboard = false, - }: SetupOptions, - { - keyboardState, - pointerState, - }: { - keyboardState: keyboardState - pointerState: pointerState - }, -): UserEventApis & { - /** - * Create a set of callbacks with different default settings but the same state. - */ - setup(options: SetupOptions): ReturnType -} { - const keyboardDefaults: KeyboardOptions = { - autoModify, - delay, - document, - keyboardMap, - } - const pointerDefaults: PointerOptions = { - skipPointerEventsCheck, - } - const pointerApiDefaults: PointerApiOptions = { - delay, - pointerMap, - } - const clickDefaults: clickOptions = { - skipHover, - } - const clipboardDefaults: ClipboardOptions = { - document, - writeToClipboard, - } - const typeDefaults: TypeOptions = { - delay, - skipAutoClose, - skipClick, - } - const uploadDefaults: UploadOptions = { - applyAccept, - } - - const userEvent: UserEvent = { - clear: (...args: Parameters) => { - return clear.call(userEvent, ...args) - }, - - click: (...args: Parameters) => { - args[1] = {...pointerDefaults, ...clickDefaults, ...args[1]} - return click.call(userEvent, ...args) - }, - - // copy needs typecasting because of the overloading - copy: ((...args: Parameters) => { - args[0] = {...clipboardDefaults, ...args[0]} - return copy.call(userEvent, ...args) - }) as typeof copy, - - // cut needs typecasting because of the overloading - cut: ((...args: Parameters) => { - args[0] = {...clipboardDefaults, ...args[0]} - return cut.call(userEvent, ...args) - }) as typeof cut, - - dblClick: (...args: Parameters) => { - args[1] = {...pointerDefaults, ...clickDefaults, ...args[1]} - return dblClick.call(userEvent, ...args) - }, - - deselectOptions: (...args: Parameters) => { - args[2] = {...pointerDefaults, ...args[2]} - return deselectOptions.call(userEvent, ...args) - }, - - hover: (...args: Parameters) => { - args[1] = {...pointerDefaults, ...args[1]} - return hover.call(userEvent, ...args) - }, - - // keyboard needs typecasting because of the overloading - keyboard: ((...args: Parameters) => { - args[1] = {...keyboardDefaults, ...args[1], keyboardState} - const ret = keyboard(...args) as keyboardState | Promise - if (ret instanceof Promise) { - return ret.then(() => undefined) - } - }) as typeof keyboard, - - // paste needs typecasting because of the overloading - paste: ((...args: Parameters) => { - args[1] = {...clipboardDefaults, ...args[1]} - return paste.call(userEvent, ...args) - }) as typeof paste, - - // pointer needs typecasting because of the overloading - pointer: ((...args: Parameters) => { - args[1] = {...pointerApiDefaults, ...args[1], pointerState, keyboardState} - const ret = pointer(...args) as pointerState | Promise - if (ret instanceof Promise) { - return ret.then(() => undefined) - } - }) as typeof pointer, - - selectOptions: (...args: Parameters) => { - args[2] = {...pointerDefaults, ...args[2]} - return selectOptions.call(userEvent, ...args) - }, - - setup: (options: SetupOptions) => { - return _setup( - { - ...keyboardDefaults, - ...pointerDefaults, - ...clickDefaults, - ...options, - }, - { - keyboardState, - pointerState, - }, - ) - }, - - tab: (...args: Parameters) => { - return tab.call(userEvent, ...args) - }, - - tripleClick: (...args: Parameters) => { - return tripleClick.call(userEvent, ...args) - }, - - // type needs typecasting because of the overloading - type: ((...args: Parameters) => { - args[2] = {...typeDefaults, ...args[2]} - return type.call(userEvent, ...args) - }) as typeof type, - - unhover: (...args: Parameters) => { - args[1] = {...pointerDefaults, ...args[1]} - return unhover.call(userEvent, ...args) - }, - - upload: (...args: Parameters) => { - args[3] = {...uploadDefaults, ...args[3]} - return upload.call(userEvent, ...args) - }, - } - - return userEvent -} diff --git a/src/setup/api.ts b/src/setup/api.ts new file mode 100644 index 00000000..15a7e695 --- /dev/null +++ b/src/setup/api.ts @@ -0,0 +1,5 @@ +export {click, dblClick, tripleClick, hover, unhover, tab} from '../convenience' +export {keyboard} from '../keyboard' +export {copy, cut, paste} from '../clipboard' +export {pointer} from '../pointer' +export {clear, deselectOptions, selectOptions, type, upload} from '../utility' diff --git a/src/setup/config.ts b/src/setup/config.ts new file mode 100644 index 00000000..ad70e929 --- /dev/null +++ b/src/setup/config.ts @@ -0,0 +1,11 @@ +import type {keyboardState} from '../keyboard/types' +import type {pointerState} from '../pointer/types' +import type {Options} from '../options' + +export interface inputDeviceState { + pointerState: pointerState + keyboardState: keyboardState +} + +export interface Config extends Required, inputDeviceState {} +export const Config = Symbol('Config') diff --git a/src/setup/directApi.ts b/src/setup/directApi.ts new file mode 100644 index 00000000..93590cb0 --- /dev/null +++ b/src/setup/directApi.ts @@ -0,0 +1,104 @@ +import type {PointerOptions} from '../utils' +import type {uploadInit} from '../utility' +import type {PointerInput} from '../pointer' +import type {UserEventApi} from '.' +import {setupDirect} from './setup' +import {Config} from './config' + +export function clear(element: Element) { + return setupDirect().clear(element) +} + +export function click(element: Element, options: Partial = {}) { + return setupDirect(options, element).click(element) +} + +export function copy(options: Partial = {}) { + return setupDirect(options).copy() +} + +export function cut(options: Partial = {}) { + return setupDirect(options).cut() +} + +export function dblClick(element: Element, options: Partial = {}) { + return setupDirect(options).dblClick(element) +} + +export function deselectOptions( + select: Element, + values: HTMLElement | HTMLElement[] | string[] | string, + options: Partial = {}, +) { + return setupDirect(options).deselectOptions(select, values) +} + +export function hover(element: Element, options: Partial = {}) { + return setupDirect(options).hover(element) +} + +export async function keyboard(text: string, options: Partial = {}) { + const instance = setupDirect(options) + const promise = instance.keyboard(text) + + return promise.then(() => instance[Config].keyboardState) +} + +export async function pointer( + input: PointerInput, + options: Partial = {}, +) { + const instance = setupDirect(options) + const promise = instance.pointer(input) + + return promise.then(() => instance[Config].pointerState) +} + +export function paste( + clipboardData?: DataTransfer | string, + options?: Partial, +) { + return setupDirect(options).paste(clipboardData) +} + +export function selectOptions( + select: Element, + values: HTMLElement | HTMLElement[] | string[] | string, + options: Partial = {}, +) { + return setupDirect(options).selectOptions(select, values) +} + +export function tripleClick(element: Element, options: Partial = {}) { + return setupDirect(options).tripleClick(element) +} + +export function type( + element: Element, + text: string, + options: Partial & Parameters[2] = {}, +) { + return setupDirect(options, element).type(element, text, options) +} + +export function unhover(element: Element, options: PointerOptions = {}) { + const instance = setupDirect(options) + instance[Config].pointerState.position.mouse.target = element + + return instance.unhover(element) +} + +export function upload( + element: HTMLElement, + fileOrFiles: File | File[], + init?: uploadInit, + options: Partial = {}, +) { + return setupDirect(options).upload(element, fileOrFiles, init) +} + +export function tab( + options: Partial & Parameters[0] = {}, +) { + return setupDirect().tab(options) +} diff --git a/src/setup/index.ts b/src/setup/index.ts new file mode 100644 index 00000000..a3114f6f --- /dev/null +++ b/src/setup/index.ts @@ -0,0 +1,19 @@ +import type * as userEventApi from './api' +import {setupMain, setupSub} from './setup' +import {Config, inputDeviceState} from './config' +import * as directApi from './directApi' + +export type {inputDeviceState} +export {Config} + +export type UserEventApi = typeof userEventApi + +export type UserEvent = UserEventApi & { + readonly setup: typeof setupSub + [Config]: Config +} + +export const userEvent = { + ...directApi, + setup: setupMain, +} as const diff --git a/src/setup/setup.ts b/src/setup/setup.ts new file mode 100644 index 00000000..3d554a51 --- /dev/null +++ b/src/setup/setup.ts @@ -0,0 +1,80 @@ +import {prepareDocument} from '../document' +import {createKeyboardState} from '../keyboard' +import {createPointerState} from '../pointer' +import {defaultOptionsDirect, defaultOptionsSetup, Options} from '../options' +import {attachClipboardStubToView, getDocumentFromNode} from '../utils' +import type {UserEvent, UserEventApi} from './index' +import {Config} from './config' +import * as userEventApi from './api' +import {wrapAsync} from './wrapAsync' + +/** + * Start a "session" with userEvent. + * All APIs returned by this function share an input device state and a default configuration. + */ +export function setupMain(options: Options = {}) { + const doc = getDocument(options) + prepareDocument(doc) + + const view = doc.defaultView ?? /* istanbul ignore next */ window + attachClipboardStubToView(view) + + return doSetup({ + ...defaultOptionsSetup, + keyboardState: createKeyboardState(), + pointerState: createPointerState(doc), + }) +} + +/** + * Setup in direct call per `userEvent.anyApi()` + */ +export function setupDirect(options: Partial = {}, node?: Node) { + const doc = getDocument(options, node) + prepareDocument(doc) + + return doSetup({ + keyboardState: createKeyboardState(), + pointerState: createPointerState(doc), + ...defaultOptionsDirect, + ...options, + }) +} + +/** + * Create a set of callbacks with different default settings but the same state. + */ +export function setupSub(this: UserEvent, options: Options) { + return doSetup({ + ...this[Config], + ...options, + }) +} + +function wrapImpl< + This extends UserEvent, + Args extends unknown[], + Impl extends (this: This, ...args: Args) => Promise, +>(impl: Impl) { + function method(this: This, ...args: Args) { + return wrapAsync(() => impl.apply(this, args)) + } + Object.defineProperty(method, 'name', {get: () => impl.name}) + + return method +} +const wrappedApis = Object.fromEntries( + Object.entries(userEventApi).map(([name, impl]) => [name, wrapImpl(impl)]), +) as UserEventApi + +function doSetup(config: Config) { + return { + ...wrappedApis, + setup: setupSub, + [Config]: config, + } +} + +function getDocument(options: Partial, node?: Node) { + return options.document ?? (node && getDocumentFromNode(node)) ?? document +} diff --git a/src/setup/wrapAsync.ts b/src/setup/wrapAsync.ts new file mode 100644 index 00000000..000b601e --- /dev/null +++ b/src/setup/wrapAsync.ts @@ -0,0 +1,10 @@ +import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' + +/** + * Wrap an internal Promise + */ +export function wrapAsync Promise) | (() => R)>( + implementation: P, +): Promise { + return getDOMTestingLibraryConfig().asyncWrapper(implementation) +} diff --git a/src/tab.ts b/src/tab.ts deleted file mode 100644 index 81ef6723..00000000 --- a/src/tab.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type {UserEvent} from './setup' - -export interface tabOptions { - shift?: boolean -} - -export function tab(this: UserEvent, {shift}: tabOptions = {}) { - this.keyboard( - shift === true - ? '{Shift>}{Tab}{/Shift}' - : shift === false - ? '[/ShiftLeft][/ShiftRight]{Tab}' - : '{Tab}', - ) -} diff --git a/src/type.ts b/src/type.ts deleted file mode 100644 index 26efb0c0..00000000 --- a/src/type.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' -import {prepareDocument} from './document' -import type {UserEvent} from './setup' -import {setSelectionRange} from './utils' -import {keyboardImplementationWrapper} from './keyboard' - -export interface typeOptions { - delay?: number - skipClick?: boolean - skipAutoClose?: boolean - initialSelectionStart?: number - initialSelectionEnd?: number -} - -export function type( - this: UserEvent, - element: Element, - text: string, - options?: typeOptions & {delay?: 0}, -): void -export function type( - this: UserEvent, - element: Element, - text: string, - options: typeOptions & {delay: number}, -): Promise -// this needs to be wrapped in the event/asyncWrapper for React's act and angular's change detection -// depending on whether it will be async. -export function type( - this: UserEvent, - element: Element, - text: string, - {delay = 0, ...options}: typeOptions = {}, -): Promise | void { - prepareDocument(element.ownerDocument) - - // we do not want to wrap in the asyncWrapper if we're not - // going to actually be doing anything async, so we only wrap - // if the delay is greater than 0 - - if (delay > 0) { - return getDOMTestingLibraryConfig().asyncWrapper(() => - typeImplementation(this, element, text, {delay, ...options}), - ) - } else { - return void typeImplementation(this, element, text, {delay, ...options}) - // prevents users from dealing with UnhandledPromiseRejectionWarning - .catch(console.error) - } -} - -async function typeImplementation( - userEvent: UserEvent, - element: Element, - text: string, - { - delay, - skipClick = false, - skipAutoClose = false, - initialSelectionStart = undefined, - initialSelectionEnd = undefined, - }: typeOptions & {delay: number}, -): Promise { - // TODO: properly type guard - // we use this workaround for now to prevent changing behavior - if ((element as {disabled?: boolean}).disabled) return - - if (!skipClick) userEvent.click(element) - - if (initialSelectionStart !== undefined) { - setSelectionRange( - element, - initialSelectionStart, - initialSelectionEnd ?? initialSelectionStart, - ) - } - - const {promise, releaseAllKeys} = keyboardImplementationWrapper(text, { - delay, - document: element.ownerDocument, - }) - - if (delay > 0) { - await promise - } - - if (!skipAutoClose) { - releaseAllKeys() - } - - // eslint-disable-next-line consistent-return -- we need to return the internal Promise so that it is catchable if we don't await - return promise -} diff --git a/src/clear.ts b/src/utility/clear.ts similarity index 74% rename from src/clear.ts rename to src/utility/clear.ts index 4809b32a..36b4683c 100644 --- a/src/clear.ts +++ b/src/utility/clear.ts @@ -1,5 +1,4 @@ -import {prepareDocument} from './document' -import type {UserEvent} from './setup' +import type {UserEvent} from '../setup' import { focus, isAllSelected, @@ -7,15 +6,13 @@ import { isEditable, prepareInput, selectAll, -} from './utils' +} from '../utils' -export function clear(this: UserEvent, element: Element) { +export async function clear(this: UserEvent, element: Element) { if (!isEditable(element) || isDisabled(element)) { throw new Error('clear()` is only supported on editable elements.') } - prepareDocument(element.ownerDocument) - focus(element) if (element.ownerDocument.activeElement !== element) { diff --git a/src/utility/index.ts b/src/utility/index.ts new file mode 100644 index 00000000..98bd2f88 --- /dev/null +++ b/src/utility/index.ts @@ -0,0 +1,4 @@ +export * from './clear' +export * from './selectOptions' +export * from './type' +export * from './upload' diff --git a/src/selectOptions.ts b/src/utility/selectOptions.ts similarity index 81% rename from src/selectOptions.ts rename to src/utility/selectOptions.ts index b838fd35..83bc430b 100644 --- a/src/selectOptions.ts +++ b/src/utility/selectOptions.ts @@ -1,37 +1,28 @@ import {createEvent, getConfig, fireEvent} from '@testing-library/dom' -import { - focus, - hasPointerEvents, - isDisabled, - isElementType, - PointerOptions, -} from './utils' -import type {UserEvent} from './setup' +import {focus, hasPointerEvents, isDisabled, isElementType} from '../utils' +import {Config, UserEvent} from '../setup' -export function selectOptions( +export async function selectOptions( this: UserEvent, select: Element, values: HTMLElement | HTMLElement[] | string[] | string, - options: PointerOptions = {}, ) { - return selectOptionsBase.call(this, true, select, values, options) + return selectOptionsBase.call(this, true, select, values) } -export function deselectOptions( +export async function deselectOptions( this: UserEvent, select: Element, values: HTMLElement | HTMLElement[] | string[] | string, - options: PointerOptions = {}, ) { - return selectOptionsBase.call(this, false, select, values, options) + return selectOptionsBase.call(this, false, select, values) } -function selectOptionsBase( +async function selectOptionsBase( this: UserEvent, newValue: boolean, select: Element, values: HTMLElement | HTMLElement[] | string[] | string, - {skipPointerEventsCheck}: PointerOptions, ) { if (!newValue && !(select as HTMLSelectElement).multiple) { throw getConfig().getElementError( @@ -70,7 +61,7 @@ function selectOptionsBase( if (isElementType(select, 'select')) { if (select.multiple) { for (const option of selectedOptions) { - const withPointerEvents = skipPointerEventsCheck + const withPointerEvents = this[Config].skipPointerEventsCheck ? true : hasPointerEvents(option) @@ -100,12 +91,12 @@ function selectOptionsBase( } } } else if (selectedOptions.length === 1) { - const withPointerEvents = skipPointerEventsCheck + const withPointerEvents = this[Config].skipPointerEventsCheck ? true : hasPointerEvents(select) // the click to open the select options if (withPointerEvents) { - this.click(select, {skipPointerEventsCheck: true}) + await this.click(select) } else { focus(select) } @@ -130,11 +121,10 @@ function selectOptionsBase( ) } } else if (select.getAttribute('role') === 'listbox') { - selectedOptions.forEach(option => { - this.hover(option, {skipPointerEventsCheck}) - this.click(option, {skipPointerEventsCheck}) - this.unhover(option, {skipPointerEventsCheck}) - }) + for (const option of selectedOptions) { + await this.click(option) + await this.unhover(option) + } } else { throw getConfig().getElementError( `Cannot select options on elements that are neither select nor listbox elements`, diff --git a/src/utility/type.ts b/src/utility/type.ts new file mode 100644 index 00000000..c66b8923 --- /dev/null +++ b/src/utility/type.ts @@ -0,0 +1,45 @@ +import type {UserEvent} from '../setup' +import {setSelectionRange} from '../utils' +import {releaseAllKeys} from '../keyboard' +import {Config} from '../setup/config' + +export interface typeOptions { + skipClick?: Config['skipClick'] + skipAutoClose?: Config['skipClick'] + initialSelectionStart?: number + initialSelectionEnd?: number +} + +export async function type( + this: UserEvent, + element: Element, + text: string, + { + skipClick = this[Config].skipClick, + skipAutoClose = this[Config].skipAutoClose, + initialSelectionStart, + initialSelectionEnd, + }: typeOptions = {}, +): Promise { + // TODO: properly type guard + // we use this workaround for now to prevent changing behavior + if ((element as {disabled?: boolean}).disabled) return + + if (!skipClick) { + await this.click(element) + } + + if (initialSelectionStart !== undefined) { + setSelectionRange( + element, + initialSelectionStart, + initialSelectionEnd ?? initialSelectionStart, + ) + } + + await this.keyboard(text) + + if (!skipAutoClose) { + await releaseAllKeys(this[Config]) + } +} diff --git a/src/upload.ts b/src/utility/upload.ts similarity index 86% rename from src/upload.ts rename to src/utility/upload.ts index b203ed25..31c9b731 100644 --- a/src/upload.ts +++ b/src/utility/upload.ts @@ -1,21 +1,16 @@ import {fireEvent, createEvent} from '@testing-library/dom' -import {blur, focus, isDisabled, isElementType} from './utils' -import type {UserEvent} from './setup' +import {blur, focus, isDisabled, isElementType} from '../utils' +import {Config, UserEvent} from '../setup' -interface uploadInit { +export interface uploadInit { changeInit?: EventInit } -export interface uploadOptions { - applyAccept?: boolean -} - -export function upload( +export async function upload( this: UserEvent, element: HTMLElement, fileOrFiles: File | File[], init?: uploadInit, - {applyAccept = false}: uploadOptions = {}, ) { const input = isElementType(element, 'label') ? element.control : element @@ -28,10 +23,12 @@ export function upload( } if (isDisabled(element)) return - this.click(element) + await this.click(element) const files = (Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]) - .filter(file => !applyAccept || isAcceptableFile(file, input.accept)) + .filter( + file => !this[Config].applyAccept || isAcceptableFile(file, input.accept), + ) .slice(0, input.multiple ? undefined : 1) // blur fires when the file selector pops up diff --git a/src/utils/index.ts b/src/utils/index.ts index 8aac89de..3a345bd8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -30,6 +30,7 @@ export * from './keyDef/readNextDescriptor' export * from './misc/eventWrapper' export * from './misc/findClosest' +export * from './misc/getDocumentFromNode' export * from './misc/isDescendantOrSelf' export * from './misc/isElementType' export * from './misc/isVisible' diff --git a/src/utils/misc/getDocumentFromNode.ts b/src/utils/misc/getDocumentFromNode.ts new file mode 100644 index 00000000..9a30d994 --- /dev/null +++ b/src/utils/misc/getDocumentFromNode.ts @@ -0,0 +1,7 @@ +export function getDocumentFromNode(el: Node) { + return isDocument(el) ? el : el.ownerDocument +} + +function isDocument(node: Node): node is Document { + return node.nodeType === 9 +} diff --git a/tests/clear.ts b/tests/clear.ts index c3f5fc0e..c52c368e 100644 --- a/tests/clear.ts +++ b/tests/clear.ts @@ -2,9 +2,9 @@ import userEvent from '#src' import {setup} from '#testHelpers/utils' describe('clear elements', () => { - test('clear text input', () => { + test('clear text input', async () => { const {element, getEventSnapshot} = setup('') - userEvent.clear(element) + await userEvent.clear(element) expect(element).toHaveValue('') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -16,9 +16,9 @@ describe('clear elements', () => { `) }) - test('clear textarea', () => { + test('clear textarea', async () => { const {element, getEventSnapshot} = setup('') - userEvent.clear(element) + await userEvent.clear(element) expect(element).toHaveValue('') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value=""] @@ -30,11 +30,11 @@ describe('clear elements', () => { `) }) - test('clear contenteditable', () => { + test('clear contenteditable', async () => { const {element, getEventSnapshot} = setup( '
hello
', ) - userEvent.clear(element) + await userEvent.clear(element) expect(element).toBeEmptyDOMElement() expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: div @@ -45,7 +45,7 @@ describe('clear elements', () => { `) }) - test('clear inputs that cannot (programmatically) have a selection', () => { + test('clear inputs that cannot (programmatically) have a selection', async () => { const { elements: [email, password, number], } = setup(` @@ -53,19 +53,19 @@ describe('clear elements', () => { `) - userEvent.clear(email) + await userEvent.clear(email) expect(email).toHaveValue('') - userEvent.clear(password) + await userEvent.clear(password) expect(password).toHaveValue('') - userEvent.clear(number) + await userEvent.clear(number) expect(number).toHaveValue(null) }) }) describe('throw error when clear is impossible', () => { - test('only editable elements can be cleared', () => { + test('only editable elements can be cleared', async () => { const { elements: [disabled, readonly, div], } = setup(` @@ -74,35 +74,45 @@ describe('throw error when clear is impossible', () => {
hello
`) - expect(() => userEvent.clear(disabled)).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.clear(disabled), + ).rejects.toThrowErrorMatchingInlineSnapshot( `clear()\` is only supported on editable elements.`, ) - expect(() => userEvent.clear(readonly)).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.clear(readonly), + ).rejects.toThrowErrorMatchingInlineSnapshot( `clear()\` is only supported on editable elements.`, ) - expect(() => userEvent.clear(div)).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.clear(div), + ).rejects.toThrowErrorMatchingInlineSnapshot( `clear()\` is only supported on editable elements.`, ) }) - test('abort if event handler prevents element being focused', () => { + test('abort if event handler prevents element being focused', async () => { const {element} = setup(``) - element.addEventListener('focus', () => element.blur()) + element.addEventListener('focus', async () => element.blur()) - expect(() => userEvent.clear(element)).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.clear(element), + ).rejects.toThrowErrorMatchingInlineSnapshot( `The element to be cleared could not be focused.`, ) }) - test('abort if event handler prevents content being selected', () => { + test('abort if event handler prevents content being selected', async () => { const {element} = setup(``) - element.addEventListener('select', () => { + element.addEventListener('select', async () => { if (element.selectionStart === 0) { element.selectionStart = 1 } }) - expect(() => userEvent.clear(element)).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.clear(element), + ).rejects.toThrowErrorMatchingInlineSnapshot( `The element content to be cleared could not be selected.`, ) }) diff --git a/tests/click/click.js b/tests/click/click.ts similarity index 71% rename from tests/click/click.js rename to tests/click/click.ts index 32981d46..7cb8a9cd 100644 --- a/tests/click/click.js +++ b/tests/click/click.ts @@ -1,9 +1,9 @@ import userEvent from '#src' import {setup, addEventListener, addListeners} from '#testHelpers/utils' -test('click in button', () => { +test('click in button', async () => { const {element, getEventSnapshot} = setup('`) - const input = element.children[0] - const button = element.children[1] + const input = element.children[0] as HTMLInputElement + const button = element.children[1] as HTMLButtonElement - addEventListener(button, 'click', () => input.focus()) + addEventListener(button, 'click', async () => input.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(`
@@ -258,11 +258,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(`
@@ -270,16 +270,16 @@ test('gives focus to the form control when clicking within a label', () => {
`) const label = element.children[0] - const span = label.firstChild + const span = label.children[0] const input = element.children[1] - userEvent.click(span) + await userEvent.click(span) expect(input).toHaveFocus() }) -test('fires no events when clicking a label with a nested control that is disabled', () => { +test('fires no events when clicking a label with a nested control that is disabled', async () => { const {element, getEventSnapshot} = setup(``) - userEvent.click(element) + await userEvent.click(element) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: label @@ -297,12 +297,12 @@ test('fires no events when clicking a label with a nested control that is disabl `) }) -test('does not crash if the label has no control', () => { +test('does not crash if the label has no control', async () => { const {element} = setup(``) - userEvent.click(element) + await userEvent.click(element) }) -test('clicking a label checks the checkbox', () => { +test('clicking a label checks the checkbox', async () => { const {element} = setup(`
@@ -312,12 +312,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(`
@@ -327,50 +327,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(eventWasFired('submit')).toBe(true) }) -test('does not submit a form when clicking on a `) - userEvent.click(element.children[0]) + await userEvent.click(element.children[0]) expect(getEventSnapshot()).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, getEventSnapshot, 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('skip consecutive radios of same group', () => { +test('skip consecutive radios of same group', async () => { const { elements: [inputA, radioA, radioB, inputB, radioC, radioD, radioE, inputC], } = setup(` @@ -474,32 +476,32 @@ test('skip consecutive radios of same group', () => { inputA.focus() - userEvent.tab() + await userEvent.tab() expect(radioA).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputB).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(radioC).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(radioD).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputC).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(radioE).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(radioC).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(inputB).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(radioB).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(inputA).toHaveFocus() }) -test('skip unchecked radios if that group has a checked one', () => { +test('skip unchecked radios if that group has a checked one', async () => { const { elements: [inputA, , inputB, radioB, inputC, , inputD], } = setup(` @@ -514,17 +516,17 @@ test('skip unchecked radios if that group has a checked one', () => { inputA.focus() - userEvent.tab() + await userEvent.tab() expect(inputB).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(radioB).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputC).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputD).toHaveFocus() }) -test('tab from active radio when another one is checked', () => { +test('tab from active radio when another one is checked', async () => { const { elements: [, , , radioB, inputC], } = setup(` @@ -537,12 +539,12 @@ test('tab from active radio when another one is checked', () => { radioB.focus() - userEvent.tab() + await userEvent.tab() expect(inputC).toHaveFocus() }) -test('calls FocusEvents with relatedTarget', () => { +test('calls FocusEvents with relatedTarget', async () => { const { elements: [element0, element1], } = setup('') @@ -551,12 +553,14 @@ test('calls FocusEvents with relatedTarget', () => { const events0 = addListeners(element0) const events1 = addListeners(element1) - userEvent.tab() + await userEvent.tab() - expect(events0.getEvents().find(e => e.type === 'blur').relatedTarget).toBe( - element1, - ) - expect(events1.getEvents().find(e => e.type === 'focus').relatedTarget).toBe( - element0, - ) + expect( + events0.getEvents().find((e): e is FocusEvent => e.type === 'blur') + ?.relatedTarget, + ).toBe(element1) + expect( + events1.getEvents().find((e): e is FocusEvent => e.type === 'focus') + ?.relatedTarget, + ).toBe(element0) }) diff --git a/tests/type/index.js b/tests/type/index.ts similarity index 85% rename from tests/type/index.js rename to tests/type/index.ts index f12c23f1..bb224ba7 100644 --- a/tests/type/index.js +++ b/tests/type/index.ts @@ -1,11 +1,10 @@ import userEvent from '#src' -import {wait} from '#src/utils' import {setup, addListeners} from '#testHelpers/utils' import '#testHelpers/custom-element' -test('types text in input', () => { +test('types text in input', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'Sup') + await userEvent.type(element, 'Sup') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Sup"] @@ -37,11 +36,11 @@ test('types text in input', () => { `) }) -test('can skip the initial click', () => { +test('can skip the initial click', async () => { const {element, getEventSnapshot, clearEventCalls} = setup('') element.focus() // users MUST focus themselves if they wish to skip the click clearEventCalls() - userEvent.type(element, 'Sup', {skipClick: true}) + await userEvent.type(element, 'Sup', {skipClick: true}) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Sup"] @@ -60,13 +59,15 @@ test('can skip the initial click', () => { `) }) -test('types text inside custom element', () => { +test('types text inside custom element', async () => { const element = document.createElement('custom-el') document.body.append(element) - const inputEl = element.shadowRoot.querySelector('input') + const inputEl = (element.shadowRoot as ShadowRoot).querySelector( + 'input', + ) as HTMLInputElement const {getEventSnapshot} = addListeners(inputEl) - userEvent.type(inputEl, 'Sup') + await userEvent.type(inputEl, 'Sup') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Sup"] @@ -98,9 +99,9 @@ test('types text inside custom element', () => { `) }) -test('types text in textarea', () => { +test('types text in textarea', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'Sup') + await userEvent.type(element, 'Sup') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value="Sup"] @@ -132,12 +133,12 @@ test('types text in textarea', () => { `) }) -test('does not fire input event when keypress calls prevent default', () => { +test('does not fire input event when keypress calls prevent default', async () => { const {element, getEventSnapshot} = setup('', { eventHandlers: {keyPress: e => e.preventDefault()}, }) - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -160,12 +161,12 @@ test('does not fire input event when keypress calls prevent default', () => { `) }) -test('does not fire keypress or input events when keydown calls prevent default', () => { +test('does not fire keypress or input events when keydown calls prevent default', async () => { const {element, getEventSnapshot} = setup('', { eventHandlers: {keyDown: e => e.preventDefault()}, }) - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -187,19 +188,19 @@ test('does not fire keypress or input events when keydown calls prevent default' `) }) -test('does not fire events when disabled', () => { +test('does not fire events when disabled', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot( `No events were fired on: input[value=""]`, ) }) -test('does not fire input when readonly', () => { +test('does not fire input when readonly', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -225,14 +226,16 @@ test('does not fire input when readonly', () => { test('should delay the typing when opts.delay is not 0', async () => { const inputValues = [{timestamp: Date.now(), value: ''}] const onInput = jest.fn(event => { - inputValues.push({timestamp: Date.now(), value: event.target.value}) + inputValues.push({ + timestamp: Date.now(), + value: ((event as InputEvent).target as HTMLInputElement).value, + }) }) const {element} = setup('', {eventHandlers: {input: onInput}}) const text = 'Hello, world!' const delay = 10 - // eslint-disable-next-line testing-library/no-await-sync-events await userEvent.type(element, text, {delay}) expect(onInput).toHaveBeenCalledTimes(text.length) @@ -245,13 +248,13 @@ test('should delay the typing when opts.delay is not 0', async () => { } }) -test('should fire events on the currently focused element', () => { +test('should fire events on the currently focused element', async () => { const {element} = setup(`
`, { eventHandlers: {keyDown: handleKeyDown}, }) - const input1 = element.children[0] - const input2 = element.children[1] + const input1 = element.children[0] as HTMLInputElement + const input2 = element.children[1] as HTMLInputElement const text = 'Hello, world!' const changeFocusLimit = 7 @@ -261,43 +264,45 @@ test('should fire events on the currently focused element', () => { } } - userEvent.type(input1, text) + await userEvent.type(input1, text) expect(input1).toHaveValue(text.slice(0, changeFocusLimit)) expect(input2).toHaveValue(text.slice(changeFocusLimit)) expect(input2).toHaveFocus() }) -test('should replace selected text', () => { +test('should replace selected text', async () => { const {element} = setup('') - userEvent.type(element, 'friend', { + await userEvent.type(element, 'friend', { initialSelectionStart: 6, initialSelectionEnd: 11, }) expect(element).toHaveValue('hello friend') }) -test('does not continue firing events when disabled during typing', () => { +test('does not continue firing events when disabled during typing', async () => { const {element} = setup('', { - eventHandlers: {input: e => (e.target.disabled = true)}, + eventHandlers: { + input: e => ((e.target as HTMLInputElement).disabled = true), + }, }) - userEvent.type(element, 'hi') + await userEvent.type(element, 'hi') expect(element).toHaveValue('h') }) // https://github.com/testing-library/user-event/issues/346 -test('typing in an empty textarea', () => { +test('typing in an empty textarea', async () => { const {element} = setup('') - userEvent.type(element, '1234') + await userEvent.type(element, '1234') expect(element).toHaveValue('1234') }) // https://github.com/testing-library/user-event/issues/321 -test('typing in a textarea with existing text', () => { +test('typing in a textarea with existing text', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '12') + await userEvent.type(element, '12') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value="Hello, 12"] @@ -328,11 +333,13 @@ test('typing in a textarea with existing text', () => { }) // https://github.com/testing-library/user-event/issues/321 -test('accepts an initialSelectionStart and initialSelectionEnd', () => { - const {element, getEventSnapshot} = setup('') +test('accepts an initialSelectionStart and initialSelectionEnd', async () => { + const {element, getEventSnapshot} = setup( + '', + ) element.setSelectionRange(0, 0) - userEvent.type(element, '12', { + await userEvent.type(element, '12', { initialSelectionStart: element.selectionStart, initialSelectionEnd: element.selectionEnd, }) @@ -370,17 +377,17 @@ test('accepts an initialSelectionStart and initialSelectionEnd', () => { }) // https://github.com/testing-library/user-event/issues/316#issuecomment-640199908 -test('can type into an input with type `email`', () => { +test('can type into an input with type `email`', async () => { const {element} = setup('') const email = 'yo@example.com' - userEvent.type(element, email) + await userEvent.type(element, email) expect(element).toHaveValue(email) }) -test('can type into an input with type `date`', () => { +test('can type into an input with type `date`', async () => { const {element, getEventSnapshot} = setup('') const date = '2020-06-29' - userEvent.type(element, date) + await userEvent.type(element, date) expect(element).toHaveValue(date) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="2020-06-29"] @@ -434,10 +441,10 @@ test('can type into an input with type `date`', () => { }) // https://github.com/testing-library/user-event/issues/336 -test('can type "-" into number inputs', () => { +test('can type "-" into number inputs', async () => { const {element, getEventSnapshot} = setup('') const negativeNumber = '-3' - userEvent.type(element, negativeNumber) + await userEvent.type(element, negativeNumber) expect(element).toHaveValue(-3) // NOTE: the input event here does not actually change the value thanks to @@ -471,9 +478,9 @@ test('can type "-" into number inputs', () => { }) // https://github.com/testing-library/user-event/issues/336 -test('can type "." into number inputs', () => { +test('can type "." into number inputs', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '3.3') + await userEvent.type(element, '3.3') expect(element).toHaveValue(3.3) expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -507,23 +514,23 @@ test('can type "." into number inputs', () => { `) }) -test('-{backspace}3', () => { +test('-{backspace}3', async () => { const {element} = setup('') const negativeNumber = '-{backspace}3' - userEvent.type(element, negativeNumber) + await userEvent.type(element, negativeNumber) expect(element).toHaveValue(3) }) -test('-a3', () => { +test('-a3', async () => { const {element} = setup('') const negativeNumber = '-a3' - userEvent.type(element, negativeNumber) + await userEvent.type(element, negativeNumber) expect(element).toHaveValue(-3) }) -test('typing an invalid input value', () => { - const {element} = setup('') - userEvent.type(element, '3-3') +test('typing an invalid input value', async () => { + const {element} = setup('') + await userEvent.type(element, '3-3') expect(element).toHaveValue(null) @@ -534,59 +541,59 @@ test('typing an invalid input value', () => { expect(element.validity.badInput).toBe(false) }) -test('should not throw error if we are trying to call type on an element without a value', () => { +test('should not throw error if we are trying to call type on an element without a value', async () => { const {element} = setup('
') - return expect(userEvent.type(element, ':(', {delay: 1})).resolves.toBe( + await expect(userEvent.type(element, ':(', {delay: 1})).resolves.toBe( undefined, ) }) -test('typing on button should not alter its value', () => { +test('typing on button should not alter its value', async () => { const {element} = setup('
`) - const label = element.children[0] - const input = element.children[1] + const label = element.children[0] as HTMLLabelElement + const input = element.children[1] as HTMLInputElement - userEvent.upload(label, files) + await userEvent.upload(label, files) - expect(input.files[0]).toStrictEqual(files[0]) - expect(input.files.item(0)).toStrictEqual(files[0]) - expect(input.files[1]).toStrictEqual(files[1]) - expect(input.files.item(1)).toStrictEqual(files[1]) + expect(input.files?.[0]).toStrictEqual(files[0]) + expect(input.files?.item(0)).toStrictEqual(files[0]) + expect(input.files?.[1]).toStrictEqual(files[1]) + expect(input.files?.item(1)).toStrictEqual(files[1]) expect(input.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('') + const {element} = setup('') - userEvent.upload(element, file) + await userEvent.upload(element, file) - expect(element.files[0]).toBeUndefined() - expect(element.files.item(0)).toBeNull() + expect(element.files?.[0]).toBeUndefined() + expect(element.files?.item(0)).toBeNull() expect(element.files).toHaveLength(0) }) -test('should call onChange/input bubbling up the event when a file is selected', () => { +test('should call onChange/input bubbling up the event when a file is selected', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) const {element: form} = setup(` @@ -136,7 +139,7 @@ test('should call onChange/input bubbling up the event when a file is selected', `) - const input = form.querySelector('input') + const input = form.querySelector('input') as HTMLInputElement const onChangeInput = jest.fn() const onChangeForm = jest.fn() @@ -154,7 +157,7 @@ test('should call onChange/input bubbling up the event when a file is selected', expect(onInputInput).toHaveBeenCalledTimes(0) expect(onInputForm).toHaveBeenCalledTimes(0) - userEvent.upload(input, file) + await userEvent.upload(input, file) expect(onChangeForm).toHaveBeenCalledTimes(1) expect(onChangeInput).toHaveBeenCalledTimes(1) @@ -170,28 +173,28 @@ test.each([ [false, 'video/*', 4], ])( 'should filter according to accept attribute applyAccept=%s, acceptAttribute=%s', - (applyAccept, acceptAttribute, expectedLength) => { + async (applyAccept, acceptAttribute, expectedLength) => { const files = [ new File(['hello'], 'hello.png', {type: 'image/png'}), new File(['there'], 'there.jpg', {type: 'audio/mp3'}), new File(['there'], 'there.csv', {type: 'text/csv'}), new File(['there'], 'there.jpg', {type: 'video/mp4'}), ] - const {element} = setup(` + const {element} = setup(` `) - userEvent.upload(element, files, undefined, {applyAccept}) + await userEvent.upload(element, files, undefined, {applyAccept}) expect(element.files).toHaveLength(expectedLength) }, ) -test('should not trigger input event when selected files are the same', () => { - const {element, eventWasFired, clearEventCalls} = setup( +test('should not trigger input event when selected files are the same', async () => { + const {element, eventWasFired, clearEventCalls} = setup( '', ) const files = [ @@ -199,62 +202,64 @@ test('should not trigger input event when selected files are the same', () => { new File(['there'], 'there.png', {type: 'image/png'}), ] - userEvent.upload(element, []) + await userEvent.upload(element, []) expect(eventWasFired('input')).toBe(false) expect(element.files).toHaveLength(0) - userEvent.upload(element, files) + await userEvent.upload(element, files) expect(eventWasFired('input')).toBe(true) expect(element.files).toHaveLength(2) clearEventCalls() - userEvent.upload(element, files) + await userEvent.upload(element, files) expect(eventWasFired('input')).toBe(false) expect(element.files).toHaveLength(2) - userEvent.upload(element, []) + await userEvent.upload(element, []) expect(eventWasFired('input')).toBe(true) expect(element.files).toHaveLength(0) }) -test('input.files implements iterable', () => { - const {element, getEvents} = setup(``) +test('input.files implements iterable', async () => { + const {element, getEvents} = setup( + ``, + ) const files = [ new File(['hello'], 'hello.png', {type: 'image/png'}), new File(['there'], 'there.png', {type: 'image/png'}), ] - userEvent.upload(element, files) - const eventTargetFiles = getEvents('input')[0].target.files + await userEvent.upload(element, files) + const eventTargetFiles = (getEvents('input')[0].target as HTMLInputElement) + .files expect(eventTargetFiles).toBe(element.files) expect(eventTargetFiles).not.toEqual(files) - expect(Array.from(eventTargetFiles)).toEqual(files) + expect(eventTargetFiles && Array.from(eventTargetFiles)).toEqual(files) }) -test('throw error if trying to use upload on an invalid element', () => { +test('throw error if trying to use upload on an invalid element', async () => { const {elements} = setup('
') - expect(() => - userEvent.upload(elements[0], "I'm only a div :("), - ).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.upload(elements[0], new File([], '')), + ).rejects.toThrowErrorMatchingInlineSnapshot( `The given DIV element does not accept file uploads`, ) - expect(() => - userEvent.upload(elements[1], "I'm a checkbox :("), - ).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.upload(elements[1], new File([], '')), + ).rejects.toThrowErrorMatchingInlineSnapshot( `The associated INPUT element does not accept file uploads`, ) }) -test('apply init options', () => { +test('apply init options', async () => { const {element, getEvents} = setup('') - userEvent.upload(element, new File([], 'hello.png'), { - clickInit: {shiftKey: true}, + await userEvent.upload(element, new File([], 'hello.png'), { changeInit: {cancelable: true}, }) diff --git a/tests/utils/dataTransfer/Clipboard.ts b/tests/utils/dataTransfer/Clipboard.ts index 1fa7fbb6..9d71b21d 100644 --- a/tests/utils/dataTransfer/Clipboard.ts +++ b/tests/utils/dataTransfer/Clipboard.ts @@ -48,7 +48,7 @@ describe('read from and write to clipboard', () => { await expect(window.navigator.clipboard.readText()).resolves.toBe('') }) - test('detach clipboard', () => { + test('detach clipboard', async () => { expect(window.navigator.clipboard).not.toBe(undefined) detachClipboardStubFromView(window) expect(window.navigator.clipboard).toBe(undefined) diff --git a/tests/utils/dataTransfer/DataTransfer.ts b/tests/utils/dataTransfer/DataTransfer.ts index 0cec5fed..eb619ab0 100644 --- a/tests/utils/dataTransfer/DataTransfer.ts +++ b/tests/utils/dataTransfer/DataTransfer.ts @@ -1,7 +1,7 @@ import {createDataTransfer, getBlobFromDataTransferItem} from '#src/utils' describe('create DataTransfer', () => { - test('plain string', () => { + test('plain string', async () => { const dt = createDataTransfer() dt.setData('text/plain', 'foo') @@ -12,7 +12,7 @@ describe('create DataTransfer', () => { expect(callback).toBeCalledWith('foo') }) - test('multi format', () => { + test('multi format', async () => { const dt = createDataTransfer() dt.setData('text/plain', 'foo') dt.setData('text/html', 'bar') @@ -30,7 +30,7 @@ describe('create DataTransfer', () => { expect(dt.getData('text')).toBe('baz') }) - test('overwrite item', () => { + test('overwrite item', async () => { const dt = createDataTransfer() dt.setData('text/plain', 'foo') dt.setData('text/plain', 'bar') @@ -39,7 +39,7 @@ describe('create DataTransfer', () => { expect(dt.getData('text')).toBe('bar') }) - test('files operation', () => { + test('files operation', async () => { const f0 = new File(['bar'], 'bar0.txt', {type: 'text/plain'}) const f1 = new File(['bar'], 'bar1.txt', {type: 'text/plain'}) const dt = createDataTransfer([f0, f1]) @@ -49,7 +49,7 @@ describe('create DataTransfer', () => { expect(dt.files.length).toBe(2) }) - test('files item', () => { + test('files item', async () => { const f0 = new File(['bar'], 'bar0.txt', {type: 'text/plain'}) const dt = createDataTransfer() dt.setData('text/html', 'foo') @@ -65,7 +65,7 @@ describe('create DataTransfer', () => { expect(callback).not.toBeCalled() }) - test('clear data', () => { + test('clear data', async () => { const f0 = new File(['bar'], 'bar0.txt', {type: 'text/plain'}) const dt = createDataTransfer() dt.setData('text/html', 'foo') @@ -85,7 +85,7 @@ describe('create DataTransfer', () => { }) }) -test('get Blob from DataTransfer', () => { +test('get Blob from DataTransfer', async () => { const dt = createDataTransfer() dt.items.add('foo', 'text/plain') dt.items.add(new File(['bar'], 'bar.txt', {type: 'text/plain'})) diff --git a/tests/utils/edit/calculateNewValue.ts b/tests/utils/edit/calculateNewValue.ts index 0540f439..8d339c44 100644 --- a/tests/utils/edit/calculateNewValue.ts +++ b/tests/utils/edit/calculateNewValue.ts @@ -3,9 +3,9 @@ import {setup} from '#testHelpers/utils' // TODO: focus the maxlength tests on the tested aspects -test('honors maxlength', () => { +test('honors maxlength', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '123') + await userEvent.type(element, '123') // NOTE: no input event when typing "3" expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -38,9 +38,9 @@ test('honors maxlength', () => { `) }) -test('honors maxlength="" as if there was no maxlength', () => { +test('honors maxlength="" as if there was no maxlength', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '123') + await userEvent.type(element, '123') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="123"] @@ -73,11 +73,11 @@ test('honors maxlength="" as if there was no maxlength', () => { `) }) -test('honors maxlength with existing text', () => { +test('honors maxlength with existing text', async () => { const {element, getEventSnapshot} = setup( '', ) - userEvent.type(element, '3') + await userEvent.type(element, '3') // NOTE: no input event when typing "3" expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -103,12 +103,12 @@ test('honors maxlength with existing text', () => { `) }) -test('honors maxlength on textarea', () => { +test('honors maxlength on textarea', async () => { const {element, getEventSnapshot} = setup( '', ) - userEvent.type(element, '3') + await userEvent.type(element, '3') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value="12"] @@ -134,10 +134,10 @@ test('honors maxlength on textarea', () => { }) // https://github.com/testing-library/user-event/issues/418 -test('ignores maxlength on input[type=number]', () => { +test('ignores maxlength on input[type=number]', async () => { const {element} = setup(``) - userEvent.type(element, '3') + await userEvent.type(element, '3') expect(element).toHaveValue(123) }) diff --git a/tests/utils/edit/isContentEditable.ts b/tests/utils/edit/isContentEditable.ts index 3d2885a3..070efd67 100644 --- a/tests/utils/edit/isContentEditable.ts +++ b/tests/utils/edit/isContentEditable.ts @@ -1,7 +1,7 @@ import {setup} from '#testHelpers/utils' import {isContentEditable} from '#src/utils' -test('report if element is contenteditable', () => { +test('report if element is contenteditable', async () => { const {elements} = setup( `
`, ) diff --git a/tests/utils/focus/blur.js b/tests/utils/focus/blur.ts similarity index 83% rename from tests/utils/focus/blur.js rename to tests/utils/focus/blur.ts index a92d8e60..ed5286f5 100644 --- a/tests/utils/focus/blur.js +++ b/tests/utils/focus/blur.ts @@ -1,7 +1,7 @@ import {blur, focus} from '#src/utils' import {setup} from '#testHelpers/utils' -test('blur a button', () => { +test('blur a button', async () => { const {element, getEventSnapshot, clearEventCalls} = setup(`