Skip to content

Commit

Permalink
feat: add userEvent.keyboard API (#581)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `userEvent.type` features a rewritten implementation shared with the new `userEvent.keyboard`. This might break code depending on unintended/undocumented behavior of the previous implementation.

BREAKING CHANGE: `userEvent.type` treats `{` and `[` as special characters.

BREAKING CHANGE: `userEvent.type` returns no Promise if called without `delay`.
  • Loading branch information
ph-fritsche committed Mar 16, 2021
1 parent e83d949 commit f251d15
Show file tree
Hide file tree
Showing 60 changed files with 2,394 additions and 1,317 deletions.
14 changes: 14 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
extends: './node_modules/kcd-scripts/eslint.js',
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.ts'],
},
},
},
rules: {
'testing-library/no-dom-import': 0,
'@typescript-eslint/non-nullable-type-assertion-style': 0,
},
}
77 changes: 72 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ change the state of the checkbox.
- [`click(element, eventInit, options)`](#clickelement-eventinit-options)
- [`dblClick(element, eventInit, options)`](#dblclickelement-eventinit-options)
- [`type(element, text, [options])`](#typeelement-text-options)
- [`upload(element, file, [{ clickInit, changeInit }])`](#uploadelement-file--clickinit-changeinit-)
- [`keyboard(text, options)`](#keyboardtext-options)
- [`upload(element, file, [{ clickInit, changeInit }], [options])`](#uploadelement-file--clickinit-changeinit--options)
- [`clear(element)`](#clearelement)
- [`selectOptions(element, values)`](#selectoptionselement-values)
- [`deselectOptions(element, values)`](#deselectoptionselement-values)
Expand Down Expand Up @@ -178,10 +179,6 @@ are typed. By default it's 0. You can use this option if your component has a
different behavior for fast or slow users. If you do this, you need to make sure
to `await`!

> To be clear, `userEvent.type` _always_ returns a promise, but you _only_ need
> to `await` the promise it returns if you're using the `delay` option.
> Otherwise everything runs synchronously and you can ignore the promise.
`type` will click the element before typing. To disable this, set the
`skipClick` option to `true`.

Expand Down Expand Up @@ -271,6 +268,76 @@ test('types into the input', () => {
})
```
### `keyboard(text, options)`
Simulates the keyboard events described by `text`. This is similar to
`userEvent.type()` but without any clicking or changing the selection range.
> You should use `userEvent.keyboard` if you want to just simulate pressing
> buttons on the keyboard. You should use `userEvent.type` if you just want to
> conveniently insert some text into an input field or textarea.
Keystrokes can be described:
- Per printable character
```js
userEvent.keyboard('foo') // translates to: f, o, o
```
The brackets `{` and `[` are used as special character and can be referenced
by doubling them.
```js
userEvent.keyboard('{{a[[') // translates to: {, a, [
```
- Per
[KeyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key)
(only supports alphanumeric values of `key`)
```js
userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o
```
This does not keep any key pressed. So `Shift` will be lifted before pressing
`f`.
- Per
[KeyboardEvent.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code)
```js
userEvent.keyboard('[ShiftLeft][KeyF][KeyO][KeyO]') // translates to: Shift, f, o, o
```
- Per legacy `userEvent.type` modifier/specialChar The modifiers like `{shift}`
(note the lowercase) will automatically be kept pressed, just like before. You
can cancel this behavior by adding a `/` to the end of the descriptor.
```js
userEvent.keyboard('{shift}{ctrl/}a{/shift}') // translates to: Shift(down), Control(down+up), a, Shift(up)
```
Keys can be kept pressed by adding a `>` to the end of the descriptor - and
lifted by adding a `/` to the beginning of the descriptor:
```js
userEvent.keyboard('{Shift>}A{/Shift}') // translates to: Shift(down), A, Shift(up)
```
`userEvent.keyboard` returns a keyboard state that can be used to continue
keyboard operations.
```js
const keyboardState = userEvent.keyboard('[ControlLeft>]') // keydown [ControlLeft]
// ... inspect some changes ...
userEvent.keyboard('a', {keyboardState}) // press [KeyA] with active ctrlKey modifier
```
The mapping of `key` to `code` is performed by a
[default key map](https://github.com/testing-library/user-event/blob/master/src/keyboard/keyMap.ts)
portraying a "default" US-keyboard. You can provide your own local keyboard
mapping per option.
```js
userEvent.keyboard('?', {keyboardMap: myOwnLocaleKeyboardMap})
```
> Future versions might try to interpolate the modifiers needed to reach a
> printable key on the keyboard. E.g. Automatically pressing `{Shift}` when
> CapsLock is not active and `A` is referenced. If you don't wish this behavior,
> you can pass `autoModify: false` when using `userEvent.keyboard` in your code.

### `upload(element, file, [{ clickInit, changeInit }], [options])`

Uploads file to an `<input>`. For uploading multiple files use `<input>` with
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
"@testing-library/dom": "^7.28.1",
"@testing-library/jest-dom": "^5.11.6",
"@types/estree": "0.0.45",
"@types/jest-in-case": "^1.0.3",
"is-ci": "^2.0.0",
"jest-in-case": "^1.0.2",
"jest-serializer-ansi": "^1.0.3",
"kcd-scripts": "^7.5.1",
"typescript": "^4.1.2"
Expand Down
78 changes: 78 additions & 0 deletions src/__tests__/keyboard/getNextKeyDef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import cases from 'jest-in-case'
import {getNextKeyDef} from 'keyboard/getNextKeyDef'
import {defaultKeyMap} from 'keyboard/keyMap'
import {keyboardKey, keyboardOptions} from 'keyboard/types'

const options: keyboardOptions = {
document,
keyboardMap: defaultKeyMap,
autoModify: false,
delay: 123,
}

cases(
'reference key per',
({text, key, code}) => {
expect(getNextKeyDef(`${text}foo`, options)).toEqual(
expect.objectContaining({
keyDef: expect.objectContaining({
key,
code,
}) as keyboardKey,
}),
)
},
{
code: {text: '[ControlLeft]', key: 'Control', code: 'ControlLeft'},
'unimplemented code': {text: '[Foo]', key: 'Unknown', code: 'Foo'},
key: {text: '{Control}', key: 'Control', code: 'ControlLeft'},
'unimplemented key': {text: '{Foo}', key: 'Foo', code: 'Unknown'},
'legacy modifier': {text: '{ctrl}', key: 'Control', code: 'ControlLeft'},
'printable character': {text: 'a', key: 'a', code: 'KeyA'},
'{ as printable': {text: '{{', key: '{', code: 'Unknown'},
'[ as printable': {text: '[[', key: '[', code: 'Unknown'},
},
)

cases(
'modifiers',
({text, modifiers}) => {
expect(getNextKeyDef(`${text}foo`, options)).toEqual(
expect.objectContaining(modifiers),
)
},
{
'no releasePrevious': {
text: '{Control}',
modifiers: {releasePrevious: false},
},
'releasePrevious per key': {
text: '{/Control}',
modifiers: {releasePrevious: true},
},
'releasePrevious per code': {
text: '[/ControlLeft]',
modifiers: {releasePrevious: true},
},
'default releaseSelf': {
text: '{Control}',
modifiers: {releaseSelf: true},
},
'keep key pressed per key': {
text: '{Control>}',
modifiers: {releaseSelf: false},
},
'keep key pressed per code': {
text: '[Control>]',
modifiers: {releaseSelf: false},
},
'no releaseSelf on legacy modifier': {
text: '{ctrl}',
modifiers: {releaseSelf: false},
},
'release legacy modifier': {
text: '{ctrl/}',
modifiers: {releaseSelf: true},
},
},
)
137 changes: 137 additions & 0 deletions src/__tests__/keyboard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import userEvent from '../../index'
import {addListeners, setup} from '../helpers/utils'

it('type without focus', () => {
const {element} = setup('<input/>')
const {getEventSnapshot} = addListeners(document.body)

userEvent.keyboard('foo')

expect(element).toHaveValue('')
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: body
body - keydown: f (102)
body - keypress: f (102)
body - keyup: f (102)
body - keydown: o (111)
body - keypress: o (111)
body - keyup: o (111)
body - keydown: o (111)
body - keypress: o (111)
body - keyup: o (111)
`)
})

it('type with focus', () => {
const {element} = setup('<input/>')
const {getEventSnapshot} = addListeners(document.body)
;(element as HTMLInputElement).focus()

userEvent.keyboard('foo')

expect(element).toHaveValue('foo')
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: body
input[value=""] - focusin
input[value=""] - keydown: f (102)
input[value=""] - keypress: f (102)
input[value="f"] - input
input[value="f"] - keyup: f (102)
input[value="f"] - keydown: o (111)
input[value="f"] - keypress: o (111)
input[value="fo"] - input
input[value="fo"] - keyup: o (111)
input[value="fo"] - keydown: o (111)
input[value="fo"] - keypress: o (111)
input[value="foo"] - input
input[value="foo"] - keyup: o (111)
`)
})

it('type asynchronous', async () => {
const {element} = setup('<input/>')
const {getEventSnapshot} = addListeners(document.body)
;(element as HTMLInputElement).focus()

// eslint-disable-next-line testing-library/no-await-sync-events
await userEvent.keyboard('foo', {delay: 1})

expect(element).toHaveValue('foo')
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: body
input[value=""] - focusin
input[value=""] - keydown: f (102)
input[value=""] - keypress: f (102)
input[value="f"] - input
input[value="f"] - keyup: f (102)
input[value="f"] - keydown: o (111)
input[value="f"] - keypress: o (111)
input[value="fo"] - input
input[value="fo"] - keyup: o (111)
input[value="fo"] - keydown: o (111)
input[value="fo"] - keypress: o (111)
input[value="foo"] - input
input[value="foo"] - keyup: o (111)
`)
})

describe('error', () => {
afterEach(() => {
;(console.error as jest.MockedFunction<typeof console.error>).mockClear()
})

it('error in sync', async () => {
const err = jest.spyOn(console, 'error')
err.mockImplementation(() => {})

userEvent.keyboard('{!')

// the catch will be asynchronous
await Promise.resolve()

expect(err).toHaveBeenCalledWith(expect.any(Error))
expect(err.mock.calls[0][0]).toHaveProperty(
'message',
'Expected key descriptor but found "!" in "{!"',
)
})

it('error in async', async () => {
const promise = userEvent.keyboard('{!', {delay: 1})

return expect(promise).rejects.toThrowError(
'Expected key descriptor but found "!" in "{!"',
)
})
})

it('continue typing with state', () => {
const {element, getEventSnapshot, clearEventCalls} = setup('<input/>')
;(element as HTMLInputElement).focus()
clearEventCalls()

const state = userEvent.keyboard('[ShiftRight>]')

expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value=""]
input[value=""] - keydown: Shift (16) {shift}
`)
clearEventCalls()

userEvent.keyboard('F[/ShiftRight]', {keyboardState: state})

expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value="F"]
input[value=""] - keydown: F (70) {shift}
input[value=""] - keypress: F (70) {shift}
input[value="F"] - input
"{CURSOR}" -> "F{CURSOR}"
input[value="F"] - keyup: F (70) {shift}
input[value="F"] - keyup: Shift (16)
`)
})
5 changes: 3 additions & 2 deletions src/__tests__/type-modifiers.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,11 @@ test('{shift}a{/shift}', () => {
`)
})

test('{capslock}a{/capslock}', () => {
test('{capslock}a{capslock}', () => {
const {element, getEventSnapshot} = setup('<input />')

userEvent.type(element, '{capslock}a{/capslock}')
// The old behavior to treat {/capslock} like {capslock} makes no sense
userEvent.type(element, '{capslock}a{capslock}')

expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value="a"]
Expand Down

0 comments on commit f251d15

Please sign in to comment.