Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: testing-library/user-event
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v14.1.1
Choose a base ref
...
head repository: testing-library/user-event
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v14.2.0
Choose a head ref
  • 6 commits
  • 19 files changed
  • 2 contributors

Commits on Apr 17, 2022

  1. docs: add yannbf as a contributor for code (#934)

    * docs: update CONTRIBUTORS.md
    
    * docs: update .all-contributorsrc
    
    Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
    allcontributors[bot] authored Apr 17, 2022

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    ee062e7 View commit details

Commits on May 10, 2022

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    bf00145 View commit details

Commits on May 12, 2022

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    ab78f3f View commit details
  2. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    6f55fee View commit details
  3. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    7ea7a77 View commit details
  4. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    31b7091 View commit details
9 changes: 9 additions & 0 deletions .all-contributorsrc
Original file line number Diff line number Diff line change
@@ -1244,6 +1244,15 @@
"contributions": [
"bug"
]
},
{
"login": "yannbf",
"name": "Yann Braga",
"avatar_url": "https://avatars.githubusercontent.com/u/1671563?v=4",
"profile": "https://github.com/yannbf",
"contributions": [
"code"
]
}
],
"commitConvention": "none",
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -171,6 +171,7 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
<tr>
<td align="center"><a href="https://github.com/karolis-cekaitis"><img src="https://avatars.githubusercontent.com/u/89905443?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Karolis Čekaitis</b></sub></a><br /><a href="https://github.com/testing-library/user-event/issues?q=author%3Akarolis-cekaitis" title="Bug reports">🐛</a> <a href="https://github.com/testing-library/user-event/commits?author=karolis-cekaitis" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/SchmitzChristian"><img src="https://avatars.githubusercontent.com/u/65352721?v=4?s=100" width="100px;" alt=""/><br /><sub><b>SchmitzChristian</b></sub></a><br /><a href="https://github.com/testing-library/user-event/issues?q=author%3ASchmitzChristian" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/yannbf"><img src="https://avatars.githubusercontent.com/u/1671563?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yann Braga</b></sub></a><br /><a href="https://github.com/testing-library/user-event/commits?author=yannbf" title="Code">💻</a></td>
</tr>
</table>

8 changes: 7 additions & 1 deletion src/setup/setup.ts
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import {
attachClipboardStubToView,
getDocumentFromNode,
setLevelRef,
wait,
} from '../utils'
import type {Instance, UserEvent, UserEventApi} from './index'
import {Config} from './config'
@@ -80,7 +81,12 @@ function wrapAndBindImpl<
function method(...args: Args) {
setLevelRef(instance[Config], ApiLevel.Call)

return wrapAsync(() => impl.apply(instance, args))
return wrapAsync(() =>
impl.apply(instance, args).then(async ret => {
await wait(instance[Config])
return ret
}),
)
}
Object.defineProperty(method, 'name', {get: () => impl.name})

12 changes: 11 additions & 1 deletion src/utility/selectOptions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import {getConfig} from '@testing-library/dom'
import {focus, hasPointerEvents, isDisabled, isElementType} from '../utils'
import {
focus,
hasPointerEvents,
isDisabled,
isElementType,
wait,
} from '../utils'
import {Config, Instance} from '../setup'

export async function selectOptions(
@@ -100,6 +106,8 @@ async function selectOptionsBase(
if (withPointerEvents) {
this.dispatchUIEvent(option, 'click')
}

await wait(this[Config])
}
} else if (selectedOptions.length === 1) {
const withPointerEvents =
@@ -124,6 +132,8 @@ async function selectOptionsBase(
this.dispatchUIEvent(select, 'mouseup')
this.dispatchUIEvent(select, 'click')
}

await wait(this[Config])
} else {
throw getConfig().getElementError(
`Cannot select multiple options on a non-multiple select`,
5 changes: 2 additions & 3 deletions src/utils/focus/selection.ts
Original file line number Diff line number Diff line change
@@ -217,14 +217,13 @@ export function moveSelection(node: Element, direction: -1 | 1) {
} else {
const selection = node.ownerDocument.getSelection()

/* istanbul ignore if */
if (!selection) {
if (!selection?.focusNode) {
return
}

if (selection.isCollapsed) {
const nextPosition = getNextCursorPosition(
selection.focusNode as Node,
selection.focusNode,
selection.focusOffset,
direction,
)
88 changes: 81 additions & 7 deletions src/utils/pointer/cssPointerEvents.ts
Original file line number Diff line number Diff line change
@@ -2,22 +2,33 @@ import {PointerEventsCheckLevel} from '../../options'
import {Config} from '../../setup'
import {ApiLevel, getLevelRef} from '..'
import {getWindow} from '../misc/getWindow'
import {isElementType} from '../misc/isElementType'

export function hasPointerEvents(element: Element): boolean {
return closestPointerEventsDeclaration(element)?.pointerEvents !== 'none'
}

function closestPointerEventsDeclaration(element: Element):
| {
pointerEvents: string
tree: Element[]
}
| undefined {
const window = getWindow(element)

for (
let el: Element | null = element;
let el: Element | null = element, tree: Element[] = [];
el?.ownerDocument;
el = el.parentElement
) {
tree.push(el)
const pointerEvents = window.getComputedStyle(el).pointerEvents
if (pointerEvents && !['inherit', 'unset'].includes(pointerEvents)) {
return pointerEvents !== 'none'
return {pointerEvents, tree}
}
}

return true
return undefined
}

const PointerEventsCheck = Symbol('Last check for pointer-events')
@@ -52,21 +63,84 @@ export function assertPointerEvents(config: Config, element: Element) {
return
}

const result = hasPointerEvents(element)
const declaration = closestPointerEventsDeclaration(element)

element[PointerEventsCheck] = {
[ApiLevel.Call]: getLevelRef(config, ApiLevel.Call),
[ApiLevel.Trigger]: getLevelRef(config, ApiLevel.Trigger),
result,
result: declaration?.pointerEvents !== 'none',
}

if (!result) {
if (declaration?.pointerEvents === 'none') {
throw new Error(
'Unable to perform pointer interaction as the element has or inherits pointer-events set to "none".',
[
`Unable to perform pointer interaction as the element ${
declaration.tree.length > 1 ? 'inherits' : 'has'
} \`pointer-events: none\`:`,
'',
printTree(declaration.tree),
].join('\n'),
)
}
}

function printTree(tree: Element[]) {
return tree
.reverse()
.map((el, i) =>
[
''.padEnd(i),
el.tagName,
el.id && `#${el.id}`,
el.hasAttribute('data-testid') &&
`(testId=${el.getAttribute('data-testid')})`,
getLabelDescr(el),
tree.length > 1 &&
i === 0 &&
' <-- This element declared `pointer-events: none`',
tree.length > 1 &&
i === tree.length - 1 &&
' <-- Asserted pointer events here',
]
.filter(Boolean)
.join(''),
)
.join('\n')
}

function getLabelDescr(element: Element) {
let label: string | undefined | null
if (element.hasAttribute('aria-label')) {
label = element.getAttribute('aria-label') as string
} else if (element.hasAttribute('aria-labelledby')) {
label = element.ownerDocument
.getElementById(element.getAttribute('aria-labelledby') as string)
?.textContent?.trim()
} else if (
isElementType(element, [
'button',
'input',
'meter',
'output',
'progress',
'select',
'textarea',
]) &&
element.labels?.length
) {
label = Array.from(element.labels)
.map(el => el.textContent?.trim())
.join('|')
} else if (isElementType(element, 'button')) {
label = element.textContent?.trim()
}
label = label?.replace(/\n/g, ' ')
if (Number(label?.length) > 30) {
label = `${label?.substring(0, 29)}…`
}
return label ? `(label=${label})` : ''
}

// With the eslint rule and prettier the bitwise operation isn't nice to read
function hasBitFlag(conf: number, flag: number) {
// eslint-disable-next-line no-bitwise
2 changes: 1 addition & 1 deletion tests/convenience/click.ts
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ describe.each([
const {element, user} = setup(`<div style="pointer-events: none"></div>`)

await expect(user[method](element)).rejects.toThrowError(
/has or inherits pointer-events/i,
/has `pointer-events: none`/i,
)
})

2 changes: 1 addition & 1 deletion tests/convenience/hover.ts
Original file line number Diff line number Diff line change
@@ -33,7 +33,7 @@ describe.each([
clearEventCalls()

await expect(user[method](element)).rejects.toThrowError(
/has or inherits pointer-events/i,
/has `pointer-events: none`/i,
)
})

4 changes: 1 addition & 3 deletions tests/keyboard/index.ts
Original file line number Diff line number Diff line change
@@ -123,9 +123,7 @@ describe('delay', () => {
const time0 = performance.now()
await user.keyboard('foo')

// we don't call delay after the last action
// TODO: Should we call it?
expect(spy).toBeCalledTimes(2)
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(2)
expect(time0).toBeLessThan(performance.now() - 20)
})

6 changes: 4 additions & 2 deletions tests/keyboard/keyboardAction.ts
Original file line number Diff line number Diff line change
@@ -184,7 +184,9 @@ test('do not call setTimeout with delay `null`', async () => {
const {user} = setup(`<div></div>`)
const spy = jest.spyOn(global, 'setTimeout')
await user.keyboard('ab')
expect(spy).toBeCalledTimes(1)
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1)

spy.mockClear()
await user.setup({delay: null}).keyboard('cd')
expect(spy).toBeCalledTimes(1)
expect(spy).not.toBeCalled()
})
4 changes: 1 addition & 3 deletions tests/pointer/index.ts
Original file line number Diff line number Diff line change
@@ -73,9 +73,7 @@ describe('delay', () => {
'[/MouseLeft]',
])

// we don't call delay after the last action
// TODO: Should we call it?
expect(spy).toBeCalledTimes(2)
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(2)
expect(time0).toBeLessThan(performance.now() - 20)
})

3 changes: 3 additions & 0 deletions tests/react/_env/setup-env.js
Original file line number Diff line number Diff line change
@@ -6,6 +6,9 @@ if (global.REACT_VERSION) {
jest.mock('react-dom', () =>
jest.requireActual(`reactDom${global.REACT_VERSION}`),
)
jest.mock('react-dom/test-utils', () =>
jest.requireActual(`reactDom${global.REACT_VERSION}/test-utils`),
)
jest.mock('react-is', () =>
jest.requireActual(`reactIs${global.REACT_VERSION}`),
)
35 changes: 34 additions & 1 deletion tests/react/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useState} from 'react'
import {render, screen} from '@testing-library/react'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '#src'
import {getUIValue} from '#src/document'
import {addListeners} from '#testHelpers'
@@ -173,3 +173,36 @@ describe('typing in a formatted input', () => {
expect(element).toHaveValue('$234')
})
})

test('change select with delayed state update', async () => {
function Select() {
const [selected, setSelected] = useState<string[]>([])

return (
<select
multiple
value={selected}
onChange={e => {
const values = Array.from(e.target.selectedOptions).map(o => o.value)
setTimeout(() => setSelected(values))
}}
>
<option>Chrome</option>
<option>Firefox</option>
<option>Opera</option>
</select>
)
}

render(<Select />)

await userEvent.selectOptions(
screen.getByRole('listbox'),
['Chrome', 'Firefox'],
{delay: 10},
)

await waitFor(() => {
expect(screen.getByRole('listbox')).toHaveValue(['Chrome', 'Firefox'])
})
})
24 changes: 13 additions & 11 deletions tests/setup/_mockApis.ts
Original file line number Diff line number Diff line change
@@ -6,13 +6,12 @@ import {Instance, UserEventApi} from '#src/setup'
// `const` are not initialized when mocking is executed, but `function` are when prefixed with `mock`
function mockApis() {}
// access the `function` as object
type mockApisRefHack = (() => void) &
{
[name in keyof UserEventApi]: {
mock: APIMock
real: UserEventApi[name]
}
type mockApisRefHack = (() => void) & {
[name in keyof UserEventApi]: {
mock: APIMock
real: UserEventApi[name]
}
}

// make the tests more readable by applying the typecast here
export function getSpy(k: keyof UserEventApi) {
@@ -34,6 +33,10 @@ interface APIMock
this: Instance,
...args: Parameters<UserEventApi[keyof UserEventApi]>
): ReturnType<UserEventApi[keyof UserEventApi]>
originalMockImplementation: (
this: Instance,
...args: Parameters<UserEventApi[keyof UserEventApi]>
) => ReturnType<UserEventApi[keyof UserEventApi]>
}

jest.mock('#src/setup/api', () => {
@@ -44,15 +47,14 @@ jest.mock('#src/setup/api', () => {
}

;(Object.keys(real) as Array<keyof UserEventApi>).forEach(key => {
const mock = jest.fn<unknown, unknown[]>(function mockImpl(
this: Instance,
...args: unknown[]
) {
const mock = jest.fn<unknown, unknown[]>(mockImpl) as unknown as APIMock
function mockImpl(this: Instance, ...args: unknown[]) {
Object.defineProperty(mock.mock.lastCall, 'this', {
get: () => this,
})
return (real[key] as Function).apply(this, args)
}) as unknown as APIMock
}
mock.originalMockImplementation = mockImpl

Object.defineProperty(mock, 'name', {
get: () => `mock-${key}`,
Loading