From 398c3327282b0faabb533744ca702a8b24a6e6b0 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 19 Oct 2021 20:45:33 +0200 Subject: [PATCH 1/2] feat: add `setup` API (#746) * feat: add `setup` API * test: use mocking to test wiring of setup * test: prevent unhandled rejection for invalid arg type * refactor: define exported apis in setup --- src/__tests__/setup.ts | 273 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 29 ++--- src/keyboard/index.ts | 2 +- src/setup.ts | 187 ++++++++++++++++++++++++++++ src/tab.ts | 2 +- src/upload.ts | 2 +- 6 files changed, 470 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/setup.ts create mode 100644 src/setup.ts diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 00000000..14ee1f10 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,273 @@ +import userEvent from 'index' +import cases from 'jest-in-case' +import {UserEventApis} from '../setup' +import {setup} from './helpers/utils' + +/// start of mocking + +// The following hacky mocking allows us to spy on imported API functions. +// The API imports are replaced with a mock with the real API as implementation. +// This way we can run the real APIs here and without repeating tests of each API implementation, +// we still can test assertions on the wiring of arguments. + +// Disable eslint rules that are not worth it here as they heavily reduce readability +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable import/order */ + +// List of API modules imported by `setup` +import '../clear' +import '../click' +import '../hover' +import '../keyboard' +import '../paste' +import '../select-options' +import '../tab' +import '../type' +import '../upload' + +// `const` are not initialized when mocking is executed, but `function` are when prefixed with `mock` +function mockSpies() {} +type mockSpiesEntry = { + mock: jest.Mock + real: UserEventApis[T] +} + +// access the `function` as object +interface mockSpiesRefHack extends Record { + (): void +} +// make the tests more readable by applying the typecast here +function getSpy(k: keyof UserEventApis) { + return (mockSpies as mockSpiesRefHack)[k].mock +} +function getReal(k: keyof UserEventApis) { + return (mockSpies as mockSpiesRefHack)[k].real +} + +/** + * Mock an API module by replacing some of the exports with spies. + */ +function mockApis(modulePath: string, ...vars: string[]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const real = jest.requireActual(modulePath) + const fake: Record = {} + for (const key of vars) { + const mock = jest.fn() + ;(mockSpies as mockSpiesRefHack)[key as keyof UserEventApis] = { + mock, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + real: real[key], + } + fake[key] = mock + } + return { + __esmodule: true, + ...real, + ...fake, + } +} + +// List of API functions per module +jest.mock('../clear', () => mockApis('../clear', 'clear')) +jest.mock('../click', () => mockApis('../click', 'click', 'dblClick')) +jest.mock('../hover', () => mockApis('../hover', 'hover', 'unhover')) +jest.mock('../keyboard', () => mockApis('../keyboard', 'keyboard')) +jest.mock('../paste', () => mockApis('../paste', 'paste')) +jest.mock('../select-options', () => + mockApis('../select-options', 'selectOptions', 'deselectOptions'), +) +jest.mock('../tab', () => mockApis('../tab', 'tab')) +jest.mock('../type', () => mockApis('../type', 'type')) +jest.mock('../upload', () => mockApis('../upload', 'upload')) + +beforeEach(() => { + jest.resetAllMocks() + + // Apply the mock implementation. Any earlier implementation would be removed per `resetAllMocks`. + for (const key of Object.keys(mockSpies as mockSpiesRefHack)) { + getSpy(key as keyof UserEventApis).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-explicit-any + getReal(key as keyof UserEventApis) as any, + ) + } +}) + +/// end of mocking + +type APICase = { + api: T + args?: unknown[] + elementArg?: number + optionsArg?: number + options?: Record + optionsSub?: Record +} + +cases( + 'apply option defaults', + ({api, args = [], elementArg, optionsArg, options, optionsSub}) => { + const {element} = setup( + ['selectOptions', 'deselectOptions'].includes(api) + ? `` + : api === 'upload' + ? `` + : ``, + ) + element.focus() + + if (elementArg !== undefined) { + args[elementArg] = element + } + + const apis = userEvent.setup(options) + + ;(apis[api] as Function)(...args) + + const spy = getSpy(api) + expect(spy).toBeCalledTimes(1) + + // ensure that options are applied correctly + if (optionsArg !== undefined && options) { + expect(spy.mock.calls[0][optionsArg]).toEqual( + expect.objectContaining(options), + ) + } + + const subOptions = { + // just flip boolean values + ...Object.fromEntries( + Object.entries(options ?? {}).map(([key, value]) => [ + key, + typeof value === 'boolean' ? !value : value, + ]), + ), + ...optionsSub, + } + const subApis = apis.setup(subOptions) + + ;(subApis[api] as Function)(...args) + + expect(spy).toBeCalledTimes(2) + + // ensure that the new set of api receives different defaults + if (optionsArg !== undefined) { + expect(spy.mock.calls[1][optionsArg]).toEqual( + expect.objectContaining(subOptions), + ) + } + }, + { + clear: {api: 'clear', elementArg: 0}, + click: { + api: 'click', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + dblClick: { + api: 'dblClick', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + hover: { + api: 'hover', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + unhover: { + api: 'unhover', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + keyboard: { + api: 'keyboard', + args: ['foo'], + optionsArg: 1, + options: { + keyboardMap: [{key: 'x', code: 'SpecialKey'}], + }, + optionsSub: { + keyboardMap: [{key: 'y', code: 'SpecialKey'}], + }, + }, + paste: {api: 'paste', args: [null, 'foo'], elementArg: 0}, + selectOptions: { + api: 'selectOptions', + args: [null, ['foo']], + elementArg: 0, + optionsArg: 3, + options: { + skipPointerEventsCheck: true, + }, + }, + deSelectOptions: { + api: 'deselectOptions', + args: [null, ['foo']], + elementArg: 0, + optionsArg: 3, + options: { + skipPointerEventsCheck: true, + }, + }, + tab: { + api: 'tab', + optionsArg: 0, + options: { + focusTrap: document.querySelector('body'), + }, + }, + type: { + api: 'type', + args: [null, 'foo'], + elementArg: 0, + optionsArg: 2, + options: { + skipClick: true, + }, + }, + upload: { + api: 'upload', + elementArg: 0, + optionsArg: 3, + options: { + applyAccept: true, + }, + }, + }, +) + +test('maintain `keyboardState` through different api calls', async () => { + const {element, getEvents} = setup(``) + element.focus() + + const api = userEvent.setup() + + expect(api.keyboard('{a>}{b>}')).toBe(undefined) + + expect(getSpy('keyboard')).toBeCalledTimes(1) + + expect(element).toHaveValue('ab') + expect(getEvents('keyup')).toHaveLength(0) + + await expect(api.keyboard('{/a}', {delay: 1})).resolves.toBe(undefined) + + expect(element).toHaveValue('ab') + expect(getEvents('keyup')).toHaveLength(1) + + api.setup({}).keyboard('b') + + expect(element).toHaveValue('abb') + // if the state is shared through api the already pressed `b` is automatically released + expect(getEvents('keyup')).toHaveLength(3) +}) diff --git a/src/index.ts b/src/index.ts index 1d241393..6b4c0820 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,11 @@ -import {click, dblClick} from './click' -import {type} from './type' -import {clear} from './clear' -import {tab} from './tab' -import {hover, unhover} from './hover' -import {upload} from './upload' -import {selectOptions, deselectOptions} from './select-options' -import {paste} from './paste' -import {keyboard, specialCharMap} from './keyboard' +import {specialCharMap} from './keyboard' +import {userEventApis, UserEventApis, setup} from './setup' -const userEvent = { - click, - dblClick, - type, - clear, - tab, - hover, - unhover, - upload, - selectOptions, - deselectOptions, - paste, - keyboard, +const userEvent: UserEventApis & { + setup: typeof setup +} = { + ...userEventApis, + setup, } export default userEvent diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index 36827e3d..51822658 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -59,7 +59,7 @@ export function keyboardImplementationWrapper( } } -function createKeyboardState(): keyboardState { +export function createKeyboardState(): keyboardState { return { activeElement: null, pressed: [], diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 00000000..b89eaea5 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,187 @@ +import {clear} from 'clear' +import {click, clickOptions, dblClick} from 'click' +import {hover, unhover} from 'hover' +import {createKeyboardState, keyboard, keyboardOptions} from 'keyboard' +import type {keyboardState} from 'keyboard/types' +import {paste} from 'paste' +import {deselectOptions, selectOptions} from 'select-options' +import {tab, tabOptions} from 'tab' +import {type} from 'type' +import {typeOptions} from 'type/typeImplementation' +import {upload, uploadOptions} from 'upload' +import {PointerOptions} from 'utils' + +export const userEventApis = { + clear, + click, + dblClick, + deselectOptions, + hover, + keyboard, + paste, + selectOptions, + tab, + type, + unhover, + upload, +} +export type UserEventApis = typeof userEventApis + +type ClickOptions = Omit + +type KeyboardOptions = Partial + +type TabOptions = Omit + +type TypeOptions = Omit< + typeOptions, + 'initialSelectionStart' | 'initialSelectionEnd' +> + +type UploadOptions = uploadOptions + +interface SetupOptions + extends ClickOptions, + KeyboardOptions, + PointerOptions, + 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 = {}) { + // TODO: prepare our document state workarounds + + return _setup(options, { + keyboardState: createKeyboardState(), + }) +} + +function _setup( + { + applyAccept, + autoModify, + delay, + document, + focusTrap, + keyboardMap, + skipAutoClose, + skipClick, + skipHover, + skipPointerEventsCheck = false, + }: SetupOptions, + { + keyboardState, + }: { + keyboardState: keyboardState + }, +): 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 clickDefaults: clickOptions = { + skipHover, + } + const tabDefaults: TabOptions = { + focusTrap, + } + const typeDefaults: TypeOptions = { + delay, + skipAutoClose, + skipClick, + } + const uploadDefaults: UploadOptions = { + applyAccept, + } + + return { + clear, + + click: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...clickDefaults, ...args[2]} + return click(...args) + }, + + dblClick: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...clickDefaults, ...args[2]} + return dblClick(...args) + }, + + deselectOptions: (...args: Parameters) => { + args[3] = {...pointerDefaults, ...args[3]} + return deselectOptions(...args) + }, + + hover: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...args[2]} + return hover(...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: (...args: Parameters) => { + return paste(...args) + }, + + selectOptions: (...args: Parameters) => { + args[3] = {...pointerDefaults, ...args[3]} + return selectOptions(...args) + }, + + setup: (options: SetupOptions) => { + return _setup( + { + ...keyboardDefaults, + ...pointerDefaults, + ...clickDefaults, + ...options, + }, + { + keyboardState, + }, + ) + }, + + tab: (...args: Parameters) => { + args[0] = {...tabDefaults, ...args[0]} + return tab(...args) + }, + + // type needs typecasting because of the overloading + type: ((...args: Parameters) => { + args[2] = {...typeDefaults, ...args[2]} + return type(...args) + }) as typeof type, + + unhover: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...args[2]} + return unhover(...args) + }, + + upload: (...args: Parameters) => { + args[3] = {...uploadDefaults, ...args[3]} + return upload(...args) + }, + } +} diff --git a/src/tab.ts b/src/tab.ts index 50680199..41c18061 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -28,7 +28,7 @@ function getNextElement( return elements[nextIndex] || elements[defaultIndex] } -interface tabOptions { +export interface tabOptions { shift?: boolean focusTrap?: Document | Element } diff --git a/src/upload.ts b/src/upload.ts index 5837ae26..b4350ebd 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -9,7 +9,7 @@ interface uploadInit { changeInit?: EventInit } -interface uploadOptions { +export interface uploadOptions { applyAccept?: boolean } From 8bded0e88adbf6cb118a3f7bde11426e1e2cae7f Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 19 Oct 2021 20:46:18 +0200 Subject: [PATCH 2/2] feat: keep track of document state in UI (#747) * feat: keep track of document state in UI * programmatically changing value resets UIValue * prevent stacking of value interceptors * programmatically changing value resets initial value * fix istanbul ignore see https://github.com/kentcdodds/kcd-scripts/issues/218 * ignore uncovered `activeElement` being `null` * intercept calls to `setSelectionRange` * fix istanbul ignore see https://github.com/kentcdodds/kcd-scripts/issues/218 * ignore omitting unnecessary event * remove obsolete util * move modules * fix istanbul ignore see https://github.com/kentcdodds/kcd-scripts/issues/218 * ignore omitting unnecessary event --- src/__tests__/document/index.ts | 94 ++++++++++++ .../edit => document}/selectionRange.ts | 0 src/__tests__/keyboard/plugin/character.ts | 17 ++- src/__tests__/type.js | 5 +- src/document/applyNative.ts | 28 ++++ src/document/index.ts | 79 ++++++++++ src/document/interceptor.ts | 58 ++++++++ src/document/selection.ts | 86 +++++++++++ src/document/value.ts | 66 +++++++++ src/keyboard/index.ts | 3 + src/keyboard/plugins/character.ts | 16 +- src/keyboard/plugins/control.ts | 8 +- src/keyboard/plugins/functional.ts | 8 +- src/keyboard/plugins/index.ts | 9 +- src/keyboard/shared/carryValue.ts | 14 -- src/keyboard/shared/fireInputEvent.ts | 140 ------------------ src/keyboard/shared/index.ts | 2 - src/keyboard/types.ts | 3 + src/type/index.ts | 3 + src/utils/edit/fireInputEvent.ts | 65 ++++++++ src/utils/edit/getValue.ts | 6 +- src/utils/edit/hasUnreliableEmptyValue.ts | 21 --- src/utils/edit/selectionRange.ts | 74 ++------- src/utils/index.ts | 2 +- 24 files changed, 524 insertions(+), 283 deletions(-) create mode 100644 src/__tests__/document/index.ts rename src/__tests__/{utils/edit => document}/selectionRange.ts (100%) create mode 100644 src/document/applyNative.ts create mode 100644 src/document/index.ts create mode 100644 src/document/interceptor.ts create mode 100644 src/document/selection.ts create mode 100644 src/document/value.ts delete mode 100644 src/keyboard/shared/carryValue.ts delete mode 100644 src/keyboard/shared/fireInputEvent.ts create mode 100644 src/utils/edit/fireInputEvent.ts delete mode 100644 src/utils/edit/hasUnreliableEmptyValue.ts diff --git a/src/__tests__/document/index.ts b/src/__tests__/document/index.ts new file mode 100644 index 00000000..b782c39c --- /dev/null +++ b/src/__tests__/document/index.ts @@ -0,0 +1,94 @@ +import {setup} from '../helpers/utils' +import { + prepareDocument, + getUIValue, + setUIValue, + getUISelection, + setUISelection, +} from '../../document' + +function prepare(element: Element) { + prepareDocument(element.ownerDocument) + // safe to call multiple times + prepareDocument(element.ownerDocument) + prepareDocument(element.ownerDocument) +} + +test('keep track of value in UI', () => { + const {element} = setup(``) + // The element has to either receive focus or be already focused when preparing. + element.focus() + + prepare(element) + + setUIValue(element, '2e-') + + expect(element).toHaveValue(null) + expect(getUIValue(element)).toBe('2e-') + + element.value = '3' + + expect(element).toHaveValue(3) + expect(getUIValue(element)).toBe('3') +}) + +test('trigger `change` event if value changed since focus/set', () => { + const {element, getEvents} = setup(``) + + prepare(element) + + element.focus() + // Invalid value is equal to empty + setUIValue(element, '2e-') + element.blur() + + expect(getEvents('change')).toHaveLength(0) + + element.focus() + // Programmatically changing value sets initial value + element.value = '3' + setUIValue(element, '3') + element.blur() + + expect(getEvents('change')).toHaveLength(0) + + element.focus() + element.value = '2' + setUIValue(element, '3') + element.blur() + + expect(getEvents('change')).toHaveLength(1) +}) + +test('maintain selection range like UI', () => { + const {element} = setup(``) + + prepare(element) + + element.setSelectionRange(1, 1) + element.focus() + setUIValue(element, 'adbc') + setUISelection(element, 2, 2) + + expect(getUISelection(element)).toEqual({ + selectionStart: 2, + selectionEnd: 2, + }) + expect(element.selectionStart).toBe(2) +}) + +test('maintain selection range on elements without support for selection range', () => { + const {element} = setup(``) + + prepare(element) + + element.focus() + setUIValue(element, '2e-') + setUISelection(element, 2, 2) + + expect(getUISelection(element)).toEqual({ + selectionStart: 2, + selectionEnd: 2, + }) + expect(element.selectionStart).toBe(null) +}) diff --git a/src/__tests__/utils/edit/selectionRange.ts b/src/__tests__/document/selectionRange.ts similarity index 100% rename from src/__tests__/utils/edit/selectionRange.ts rename to src/__tests__/document/selectionRange.ts diff --git a/src/__tests__/keyboard/plugin/character.ts b/src/__tests__/keyboard/plugin/character.ts index c8b6b1f5..2179766a 100644 --- a/src/__tests__/keyboard/plugin/character.ts +++ b/src/__tests__/keyboard/plugin/character.ts @@ -1,3 +1,4 @@ +import {getUIValue} from 'document/value' import userEvent from 'index' import {setup} from '__tests__/helpers/utils' @@ -24,21 +25,23 @@ test('type [Enter] in contenteditable', () => { }) test.each([ - ['1e--5', 1e-5, undefined, 4], + ['1e--5', 1e-5, '1e-5', 4], ['1--e--5', null, '1--e5', 5], ['.-1.-e--5', null, '.-1-e5', 6], - ['1.5e--5', 1.5e-5, undefined, 6], - ['1e5-', 1e5, undefined, 3], + ['1.5e--5', 1.5e-5, '1.5e-5', 6], + ['1e5-', 1e5, '1e5', 3], ])( 'type invalid values into ', - (text, expectedValue, expectedCarryValue, expectedInputEvents) => { - const {element, getEvents} = setup(``) + (text, expectedValue, expectedUiValue, expectedInputEvents) => { + const {element, getEvents} = setup( + ``, + ) element.focus() - const state = userEvent.keyboard(text) + userEvent.keyboard(text) expect(element).toHaveValue(expectedValue) - expect(state).toHaveProperty('carryValue', expectedCarryValue) + expect(getUIValue(element)).toBe(expectedUiValue) expect(getEvents('input')).toHaveLength(expectedInputEvents) }, ) diff --git a/src/__tests__/type.js b/src/__tests__/type.js index d4c43eaa..882ec5df 100644 --- a/src/__tests__/type.js +++ b/src/__tests__/type.js @@ -1503,10 +1503,7 @@ describe('promise rejections', () => { console.error.mockReset() }) - test.each([ - ['foo', '[{', 'Unable to find the "window"'], - [document.body, '[{', 'Expected key descriptor but found "{"'], - ])( + test.each([[document.body, '[{', 'Expected key descriptor but found "{"']])( 'catch promise rejections and report to the console on synchronous calls', async (element, text, errorMessage) => { const errLog = jest diff --git a/src/document/applyNative.ts b/src/document/applyNative.ts new file mode 100644 index 00000000..6ef2b9f1 --- /dev/null +++ b/src/document/applyNative.ts @@ -0,0 +1,28 @@ +/** + * React tracks the changes on element properties. + * This workaround tries to alter the DOM element without React noticing, + * so that it later picks up the change. + * + * @see https://github.com/facebook/react/blob/148f8e497c7d37a3c7ab99f01dec2692427272b1/packages/react-dom/src/client/inputValueTracking.js#L51-L104 + */ +export function applyNative( + element: T, + propName: P, + propValue: T[P], +) { + const descriptor = Object.getOwnPropertyDescriptor(element, propName) + const nativeDescriptor = Object.getOwnPropertyDescriptor( + element.constructor.prototype, + propName, + ) + + if (descriptor && nativeDescriptor) { + Object.defineProperty(element, propName, nativeDescriptor) + } + + element[propName] = propValue + + if (descriptor) { + Object.defineProperty(element, propName, descriptor) + } +} diff --git a/src/document/index.ts b/src/document/index.ts new file mode 100644 index 00000000..d3d2d258 --- /dev/null +++ b/src/document/index.ts @@ -0,0 +1,79 @@ +import {fireEvent} from '@testing-library/dom' +import {prepareSelectionInterceptor} from './selection' +import { + getInitialValue, + prepareValueInterceptor, + setInitialValue, +} from './value' + +const isPrepared = Symbol('Node prepared with document state workarounds') + +declare global { + interface Node { + [isPrepared]?: typeof isPrepared + } +} + +export function prepareDocument(document: Document) { + if (document[isPrepared]) { + return + } + + document.addEventListener( + 'focus', + e => { + const el = e.target as Node + + prepareElement(el) + }, + { + capture: true, + passive: true, + }, + ) + + // Our test environment defaults to `document.body` as `activeElement`. + // In other environments this might be `null` when preparing. + // istanbul ignore else + if (document.activeElement) { + prepareElement(document.activeElement) + } + + document.addEventListener( + 'blur', + e => { + const el = e.target as HTMLInputElement + const initialValue = getInitialValue(el) + if (typeof initialValue === 'string' && el.value !== initialValue) { + fireEvent.change(el) + } + }, + { + capture: true, + passive: true, + }, + ) + + document[isPrepared] = isPrepared +} + +function prepareElement(el: Node | HTMLInputElement) { + if ('value' in el) { + setInitialValue(el) + } + + if (el[isPrepared]) { + return + } + + if ('value' in el) { + prepareValueInterceptor(el) + prepareSelectionInterceptor(el) + } + + el[isPrepared] = isPrepared +} + +export {applyNative} from './applyNative' +export {getUIValue, setUIValue} from './value' +export {getUISelection, hasUISelection, setUISelection} from './selection' diff --git a/src/document/interceptor.ts b/src/document/interceptor.ts new file mode 100644 index 00000000..4c9f3558 --- /dev/null +++ b/src/document/interceptor.ts @@ -0,0 +1,58 @@ +const Interceptor = Symbol('Interceptor for programmatical calls') + +interface Interceptable { + [Interceptor]?: typeof Interceptor +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type anyFunc = (...a: any[]) => any +type Params = Prop extends anyFunc ? Parameters : [Prop] +type ImplReturn = Prop extends anyFunc ? Parameters : Prop + +export function prepareInterceptor< + ElementType extends Element, + PropName extends keyof ElementType, +>( + element: ElementType, + propName: PropName, + interceptorImpl: ( + this: ElementType, + ...args: Params + ) => ImplReturn, +) { + const prototypeDescriptor = Object.getOwnPropertyDescriptor( + element.constructor.prototype, + propName, + ) + + const target = prototypeDescriptor?.set ? 'set' : 'value' + if ( + typeof prototypeDescriptor?.[target] !== 'function' || + (prototypeDescriptor[target] as Interceptable)[Interceptor] + ) { + return + } + + const realFunc = prototypeDescriptor[target] as ( + this: ElementType, + ...args: unknown[] + ) => unknown + function intercept( + this: ElementType, + ...args: Params + ) { + const realArgs = interceptorImpl.call(this, ...args) + + if (target === 'set') { + realFunc.call(this, realArgs) + } else { + realFunc.call(this, ...realArgs) + } + } + ;(intercept as Interceptable)[Interceptor] = Interceptor + + Object.defineProperty(element.constructor.prototype, propName, { + ...prototypeDescriptor, + [target]: intercept, + }) +} diff --git a/src/document/selection.ts b/src/document/selection.ts new file mode 100644 index 00000000..7e104e2d --- /dev/null +++ b/src/document/selection.ts @@ -0,0 +1,86 @@ +import {prepareInterceptor} from './interceptor' + +const UISelection = Symbol('Displayed selection in UI') + +interface Value extends Number { + [UISelection]?: typeof UISelection +} + +declare global { + interface Element { + [UISelection]?: {start: number; end: number} + } +} + +function setSelectionInterceptor( + this: HTMLInputElement | HTMLTextAreaElement, + start: number | Value | null, + end: number | null, + direction: 'forward' | 'backward' | 'none' = 'none', +) { + const isUI = start && typeof start === 'object' && start[UISelection] + + this[UISelection] = isUI + ? {start: start.valueOf(), end: Number(end)} + : undefined + + return [Number(start), end, direction] as Parameters< + HTMLInputElement['setSelectionRange'] + > +} + +export function prepareSelectionInterceptor( + element: HTMLInputElement | HTMLTextAreaElement, +) { + prepareInterceptor(element, 'setSelectionRange', setSelectionInterceptor) +} + +export function setUISelection( + element: HTMLInputElement | HTMLTextAreaElement, + start: number, + end: number, +) { + element[UISelection] = {start, end} + + if (element.selectionStart === start && element.selectionEnd === end) { + return + } + + // eslint-disable-next-line no-new-wrappers + const startObj = new Number(start) + ;(startObj as Value)[UISelection] = UISelection + + try { + element.setSelectionRange(startObj as number, end) + } catch { + // DOMException for invalid state is expected when calling this + // on an element without support for setSelectionRange + } +} + +export function getUISelection( + element: HTMLInputElement | HTMLTextAreaElement, +) { + const ui = element[UISelection] + return ui === undefined + ? { + selectionStart: element.selectionStart, + selectionEnd: element.selectionEnd, + } + : { + selectionStart: ui.start, + selectionEnd: ui.end, + } +} + +export function clearUISelection( + element: HTMLInputElement | HTMLTextAreaElement, +) { + element[UISelection] = undefined +} + +export function hasUISelection( + element: HTMLInputElement | HTMLTextAreaElement, +) { + return Boolean(element[UISelection]) +} diff --git a/src/document/value.ts b/src/document/value.ts new file mode 100644 index 00000000..279db7b2 --- /dev/null +++ b/src/document/value.ts @@ -0,0 +1,66 @@ +import {applyNative} from './applyNative' +import {prepareInterceptor} from './interceptor' +import {clearUISelection} from './selection' + +const UIValue = Symbol('Displayed value in UI') +const InitialValue = Symbol('Initial value to compare on blur') + +type Value = { + [UIValue]?: typeof UIValue + toString(): string +} + +declare global { + interface Element { + [UIValue]?: string + [InitialValue]?: string + } +} + +function valueInterceptor( + this: HTMLInputElement | HTMLTextAreaElement, + v: Value | string, +) { + const isUI = typeof v === 'object' && v[UIValue] + + this[UIValue] = isUI ? String(v) : undefined + if (!isUI) { + this[InitialValue] = String(v) + + // Programmatically setting the value property + // moves the cursor to the end of the input. + clearUISelection(this) + } + + return String(v) +} + +export function prepareValueInterceptor(element: HTMLInputElement) { + prepareInterceptor(element, 'value', valueInterceptor) +} + +export function setUIValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { + applyNative(element, 'value', { + [UIValue]: UIValue, + toString: () => value, + } as unknown as string) +} + +export function getUIValue(element: HTMLInputElement | HTMLTextAreaElement) { + return element[UIValue] === undefined ? element.value : element[UIValue] +} + +export function setInitialValue( + element: HTMLInputElement | HTMLTextAreaElement, +) { + element[InitialValue] = element.value +} + +export function getInitialValue( + element: HTMLInputElement | HTMLTextAreaElement, +) { + return element[InitialValue] +} diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index 51822658..64becce2 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -1,4 +1,5 @@ import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {prepareDocument} from '../document' import {keyboardImplementation, releaseAllKeys} from './keyboardImplementation' import {defaultKeyMap} from './keyMap' import {keyboardState, keyboardOptions, keyboardKey} from './types' @@ -52,6 +53,8 @@ export function keyboardImplementationWrapper( keyboardMap, } + prepareDocument(document) + return { promise: keyboardImplementation(text, options, state), state, diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts index 5a102a21..8aa97ffc 100644 --- a/src/keyboard/plugins/character.ts +++ b/src/keyboard/plugins/character.ts @@ -3,11 +3,12 @@ */ import {fireEvent} from '@testing-library/dom' -import {fireChangeForInputTimeIfValid, fireInputEvent} from '../shared' +import {fireChangeForInputTimeIfValid} from '../shared' import {behaviorPlugin} from '../types' import { buildTimeValue, calculateNewValue, + fireInputEvent, getSpaceUntilMaxLength, getValue, isClickableInput, @@ -112,18 +113,14 @@ export const keypressBehavior: behaviorPlugin[] = [ matches: (keyDef, element) => keyDef.key?.length === 1 && isElementType(element, 'input', {type: 'number', readOnly: false}), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element) => { if (!/[\d.\-e]/.test(keyDef.key as string)) { return } - const oldValue = - state.carryValue ?? getValue(element) ?? /* istanbul ignore next */ '' - const {newValue, newSelectionStart} = calculateNewValue( keyDef.key as string, element as HTMLElement, - oldValue, ) // the browser allows some invalid input but not others @@ -146,13 +143,6 @@ export const keypressBehavior: behaviorPlugin[] = [ inputType: 'insertText', }, }) - - const appliedValue = getValue(element) - if (appliedValue === newValue) { - state.carryValue = undefined - } else { - state.carryValue = newValue - } }, }, { diff --git a/src/keyboard/plugins/control.ts b/src/keyboard/plugins/control.ts index bd5d8c72..c38008f5 100644 --- a/src/keyboard/plugins/control.ts +++ b/src/keyboard/plugins/control.ts @@ -6,6 +6,7 @@ import {behaviorPlugin} from '../types' import { calculateNewValue, + fireInputEvent, getValue, isContentEditable, isCursorAtEnd, @@ -13,7 +14,6 @@ import { isElementType, setSelectionRange, } from '../../utils' -import {carryValue, fireInputEvent} from '../shared' export const keydownBehavior: behaviorPlugin[] = [ { @@ -48,11 +48,11 @@ export const keydownBehavior: behaviorPlugin[] = [ { matches: (keyDef, element) => keyDef.key === 'Delete' && isEditable(element) && !isCursorAtEnd(element), - handle: (keDef, element, options, state) => { + handle: (keDef, element) => { const {newValue, newSelectionStart} = calculateNewValue( '', element as HTMLElement, - state.carryValue, + undefined, undefined, 'forward', ) @@ -64,8 +64,6 @@ export const keydownBehavior: behaviorPlugin[] = [ inputType: 'deleteContentForward', }, }) - - carryValue(element, state, newValue) }, }, ] diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts index 564b0a70..80c2511e 100644 --- a/src/keyboard/plugins/functional.ts +++ b/src/keyboard/plugins/functional.ts @@ -6,6 +6,7 @@ import {fireEvent} from '@testing-library/dom' import { calculateNewValue, + fireInputEvent, hasFormSubmit, isClickableInput, isCursorAtStart, @@ -13,7 +14,6 @@ import { isElementType, } from '../../utils' import {getKeyEventProps, getMouseEventProps} from '../getEventProps' -import {carryValue, fireInputEvent} from '../shared' import {behaviorPlugin} from '../types' const modifierKeys = { @@ -59,11 +59,11 @@ export const keydownBehavior: behaviorPlugin[] = [ keyDef.key === 'Backspace' && isEditable(element) && !isCursorAtStart(element), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element) => { const {newValue, newSelectionStart} = calculateNewValue( '', element as HTMLElement, - state.carryValue, + undefined, undefined, 'backward', ) @@ -75,8 +75,6 @@ export const keydownBehavior: behaviorPlugin[] = [ inputType: 'deleteContentBackward', }, }) - - carryValue(element, state, newValue) }, }, ] diff --git a/src/keyboard/plugins/index.ts b/src/keyboard/plugins/index.ts index 54c2f283..5e4d041e 100644 --- a/src/keyboard/plugins/index.ts +++ b/src/keyboard/plugins/index.ts @@ -1,5 +1,5 @@ import {behaviorPlugin} from '../types' -import {isElementType, setSelectionRange} from '../../utils' +import {getValue, isElementType, setSelectionRange} from '../../utils' import * as arrowKeys from './arrow' import * as controlKeys from './control' import * as characterKeys from './character' @@ -10,14 +10,11 @@ export const replaceBehavior: behaviorPlugin[] = [ matches: (keyDef, element) => keyDef.key === 'selectall' && isElementType(element, ['input', 'textarea']), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element) => { setSelectionRange( element, 0, - ( - state.carryValue ?? - (element as HTMLInputElement | HTMLTextAreaElement).value - ).length, + getValue(element as HTMLInputElement).length, ) }, }, diff --git a/src/keyboard/shared/carryValue.ts b/src/keyboard/shared/carryValue.ts deleted file mode 100644 index 983c0cd4..00000000 --- a/src/keyboard/shared/carryValue.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {getValue, hasUnreliableEmptyValue} from '../../utils' -import {keyboardState} from '../types' - -export function carryValue( - element: Element, - state: keyboardState, - newValue: string, -) { - const value = getValue(element) - state.carryValue = - value !== newValue && value === '' && hasUnreliableEmptyValue(element) - ? newValue - : undefined -} diff --git a/src/keyboard/shared/fireInputEvent.ts b/src/keyboard/shared/fireInputEvent.ts deleted file mode 100644 index 7541cb72..00000000 --- a/src/keyboard/shared/fireInputEvent.ts +++ /dev/null @@ -1,140 +0,0 @@ -import {fireEvent} from '@testing-library/dom' -import { - isElementType, - getValue, - hasUnreliableEmptyValue, - isContentEditable, - setSelectionRange, - getSelectionRange, -} from '../../utils' - -export function fireInputEvent( - element: HTMLElement, - { - newValue, - newSelectionStart, - eventOverrides, - }: { - newValue: string - newSelectionStart: number - eventOverrides: Partial[1]> & { - [k: string]: unknown - } - }, -) { - // apply the changes before firing the input event, so that input handlers can access the altered dom and selection - if (isContentEditable(element)) { - applyNative(element, 'textContent', newValue) - } else /* istanbul ignore else */ if ( - isElementType(element, ['input', 'textarea']) - ) { - applyNative(element, 'value', newValue) - } else { - // TODO: properly type guard - throw new Error('Invalid Element') - } - setSelectionRangeAfterInput(element, newSelectionStart) - - fireEvent.input(element, { - ...eventOverrides, - }) - - setSelectionRangeAfterInputHandler(element, newValue, newSelectionStart) -} - -function setSelectionRangeAfterInput( - element: Element, - newSelectionStart: number, -) { - setSelectionRange(element, newSelectionStart, newSelectionStart) -} - -function setSelectionRangeAfterInputHandler( - element: Element, - newValue: string, - newSelectionStart: number, -) { - const value = getValue(element) as string - - // don't apply this workaround on elements that don't necessarily report the visible value - e.g. number - // TODO: this could probably be only applied when there is keyboardState.carryValue - const isUnreliableValue = value === '' && hasUnreliableEmptyValue(element) - - if (!isUnreliableValue && value === newValue) { - const {selectionStart} = getSelectionRange(element) - if (selectionStart === value.length) { - // The value was changed as expected, but the cursor was moved to the end - // TODO: this could probably be only applied when we work around a framework setter on the element in applyNative - setSelectionRange(element, newSelectionStart, newSelectionStart) - } - } -} - -const initial = Symbol('initial input value/textContent') -const onBlur = Symbol('onBlur') -declare global { - interface Element { - [initial]?: string - [onBlur]?: EventListener - } -} - -/** - * React tracks the changes on element properties. - * This workaround tries to alter the DOM element without React noticing, - * so that it later picks up the change. - * - * @see https://github.com/facebook/react/blob/148f8e497c7d37a3c7ab99f01dec2692427272b1/packages/react-dom/src/client/inputValueTracking.js#L51-L104 - */ -function applyNative( - element: T, - propName: P, - propValue: T[P], -) { - const descriptor = Object.getOwnPropertyDescriptor(element, propName) - const nativeDescriptor = Object.getOwnPropertyDescriptor( - element.constructor.prototype, - propName, - ) - - if (descriptor && nativeDescriptor) { - Object.defineProperty(element, propName, nativeDescriptor) - } - - // Keep track of the initial value to determine if a change event should be dispatched. - // CONSTRAINT: We can not determine what happened between focus event and our first API call. - if (element[initial] === undefined) { - element[initial] = String(element[propName]) - } - - element[propName] = propValue - - // Add an event listener for the blur event to the capture phase on the window. - // CONSTRAINT: Currently there is no cross-platform solution to unshift the event handler stack. - // Our change event might occur after other event handlers on the blur event have been processed. - if (!element[onBlur]) { - element.ownerDocument.defaultView?.addEventListener( - 'blur', - (element[onBlur] = () => { - const initV = element[initial] - - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete element[onBlur] - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete element[initial] - - if (String(element[propName]) !== initV) { - fireEvent.change(element) - } - }), - { - capture: true, - once: true, - }, - ) - } - - if (descriptor) { - Object.defineProperty(element, propName, descriptor) - } -} diff --git a/src/keyboard/shared/index.ts b/src/keyboard/shared/index.ts index 30de28ac..3773daa9 100644 --- a/src/keyboard/shared/index.ts +++ b/src/keyboard/shared/index.ts @@ -1,3 +1 @@ -export * from './carryValue' export * from './fireChangeForInputTimeIfValid' -export * from './fireInputEvent' diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts index 70cead57..1d66efcb 100644 --- a/src/keyboard/types.ts +++ b/src/keyboard/types.ts @@ -30,6 +30,9 @@ export type keyboardState = { For HTMLInputElements type='number': If the last input char is '.', '-' or 'e', the IDL value attribute does not reflect the input value. + + @deprecated The document state workaround in `src/document/value.ts` keeps track + of UI value diverging from value property. */ carryValue?: string diff --git a/src/type/index.ts b/src/type/index.ts index 74c99edd..1bc1377f 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -1,4 +1,5 @@ import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {prepareDocument} from 'document' import {typeImplementation, typeOptions} from './typeImplementation' export function type( @@ -18,6 +19,8 @@ export function type( 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 diff --git a/src/utils/edit/fireInputEvent.ts b/src/utils/edit/fireInputEvent.ts new file mode 100644 index 00000000..7e7bcce0 --- /dev/null +++ b/src/utils/edit/fireInputEvent.ts @@ -0,0 +1,65 @@ +import {fireEvent} from '@testing-library/dom' +import {isElementType} from '../misc/isElementType' +import {applyNative, hasUISelection, setUIValue} from '../../document' +import {isContentEditable} from './isContentEditable' +import {setSelectionRange} from './selectionRange' + +export function fireInputEvent( + element: HTMLElement, + { + newValue, + newSelectionStart, + eventOverrides, + }: { + newValue: string + newSelectionStart: number + eventOverrides: Partial[1]> & { + [k: string]: unknown + } + }, +) { + // apply the changes before firing the input event, so that input handlers can access the altered dom and selection + if (isContentEditable(element)) { + applyNative(element, 'textContent', newValue) + } else /* istanbul ignore else */ if ( + isElementType(element, ['input', 'textarea']) + ) { + setUIValue(element, newValue) + } else { + // TODO: properly type guard + throw new Error('Invalid Element') + } + setSelectionRangeAfterInput(element, newSelectionStart) + + fireEvent.input(element, { + ...eventOverrides, + }) + + setSelectionRangeAfterInputHandler(element, newSelectionStart) +} + +function setSelectionRangeAfterInput( + element: Element, + newSelectionStart: number, +) { + setSelectionRange(element, newSelectionStart, newSelectionStart) +} + +function setSelectionRangeAfterInputHandler( + element: Element, + newSelectionStart: number, +) { + // On controlled inputs the selection changes without a call to + // either the `value` setter or the `setSelectionRange` method. + // So if our tracked position for UI still exists and derives from a valid selectionStart, + // the cursor was moved due to an input being controlled. + + if ( + isElementType(element, ['input', 'textarea']) && + typeof element.selectionStart === 'number' && + element.selectionStart !== newSelectionStart && + hasUISelection(element) + ) { + setSelectionRange(element, newSelectionStart, newSelectionStart) + } +} diff --git a/src/utils/edit/getValue.ts b/src/utils/edit/getValue.ts index 126e258f..1f010332 100644 --- a/src/utils/edit/getValue.ts +++ b/src/utils/edit/getValue.ts @@ -1,5 +1,9 @@ +import {getUIValue} from '../../document' import {isContentEditable} from './isContentEditable' +export function getValue( + element: T, +): T extends HTMLInputElement | HTMLTextAreaElement ? string : string | null export function getValue(element: Element | null): string | null { // istanbul ignore if if (!element) { @@ -8,5 +12,5 @@ export function getValue(element: Element | null): string | null { if (isContentEditable(element)) { return element.textContent } - return (element as HTMLInputElement).value + return getUIValue(element as HTMLInputElement) ?? null } diff --git a/src/utils/edit/hasUnreliableEmptyValue.ts b/src/utils/edit/hasUnreliableEmptyValue.ts deleted file mode 100644 index 7eb505d9..00000000 --- a/src/utils/edit/hasUnreliableEmptyValue.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {isElementType} from '../misc/isElementType' - -enum unreliableValueInputTypes { - 'number' = 'number', -} - -/** - * Check if an empty IDL value on the element could mean a derivation of displayed value and IDL value - */ -export function hasUnreliableEmptyValue( - element: Element, -): element is HTMLInputElement & {type: unreliableValueInputTypes} { - return ( - isElementType(element, 'input') && - Boolean( - unreliableValueInputTypes[ - element.type as keyof typeof unreliableValueInputTypes - ], - ) - ) -} diff --git a/src/utils/edit/selectionRange.ts b/src/utils/edit/selectionRange.ts index a40bda48..57c83df4 100644 --- a/src/utils/edit/selectionRange.ts +++ b/src/utils/edit/selectionRange.ts @@ -1,56 +1,12 @@ import {isElementType} from '../misc/isElementType' +import {getUISelection, setUISelection} from '../../document' -// https://github.com/jsdom/jsdom/blob/c2fb8ff94917a4d45e2398543f5dd2a8fed0bdab/lib/jsdom/living/nodes/HTMLInputElement-impl.js#L45 -enum selectionSupportType { - 'text' = 'text', - 'search' = 'search', - 'url' = 'url', - 'tel' = 'tel', - 'password' = 'password', -} - -const InputSelection = Symbol('inputSelection') -type InputWithInternalSelection = HTMLInputElement & { - [InputSelection]?: { - selectionStart: number - selectionEnd: number - } -} - -export function hasSelectionSupport( - element: Element, -): element is - | HTMLTextAreaElement - | (HTMLInputElement & {type: selectionSupportType}) { - return ( - isElementType(element, 'textarea') || - (isElementType(element, 'input') && - Boolean( - selectionSupportType[element.type as keyof typeof selectionSupportType], - )) - ) -} - -export function getSelectionRange( - element: Element, -): { +export function getSelectionRange(element: Element): { selectionStart: number | null selectionEnd: number | null } { - if (hasSelectionSupport(element)) { - return { - selectionStart: element.selectionStart, - selectionEnd: element.selectionEnd, - } - } - - if (isElementType(element, 'input')) { - return ( - (element as InputWithInternalSelection)[InputSelection] ?? { - selectionStart: null, - selectionEnd: null, - } - ) + if (isElementType(element, ['input', 'textarea'])) { + return getUISelection(element) } const selection = element.ownerDocument.getSelection() @@ -76,8 +32,14 @@ export function setSelectionRange( newSelectionStart: number, newSelectionEnd: number, ) { + if (isElementType(element, ['input', 'textarea'])) { + return setUISelection(element, newSelectionStart, newSelectionEnd) + } + const {selectionStart, selectionEnd} = getSelectionRange(element) + // Prevent unnecessary select events + // istanbul ignore next if ( selectionStart === newSelectionStart && selectionEnd === newSelectionEnd @@ -85,22 +47,6 @@ export function setSelectionRange( return } - if (hasSelectionSupport(element)) { - element.setSelectionRange(newSelectionStart, newSelectionEnd) - } - - if (isElementType(element, 'input')) { - ;(element as InputWithInternalSelection)[InputSelection] = { - selectionStart: newSelectionStart, - selectionEnd: newSelectionEnd, - } - } - - // Moving the selection inside or