diff --git a/docs/api/browser/interactivity.md b/docs/api/browser/interactivity.md
index b403cdcd1..38585aaaf 100644
--- a/docs/api/browser/interactivity.md
+++ b/docs/api/browser/interactivity.md
@@ -235,6 +235,58 @@ await page.getByRole('tablist').wheel({ direction: 'right' })
This method is intended for testing UI that explicitly listens to `wheel` events (e.g., custom zoom controls, horizontal tab scrolling, canvas interactions). If you need to scroll the page to bring an element into view, rely on the built-in automatic scrolling functionality provided by other `userEvent` methods or [locator actions](/api/browser/locators#methods) instead.
:::
+## userEvent.pointer 5.1.0 {#userevent-pointer}
+
+```ts
+function pointer(options: UserEventPointerInput): Promise
+```
+
+Performs one or more pointer actions. Pass a key string, an action object, or an array of strings and objects. Key strings use [user-event `pointer` syntax](https://testing-library.com/docs/user-event/pointer). For example, `[MouseLeft]` presses and releases the left mouse button, `[MouseLeft>]` keeps it pressed, and `[/MouseLeft]` releases it.
+
+::: tip
+The `pointer` method is a low-level API for composing complex pointer interactions and performing actions that dedicated methods do not support, such as dragging an element to a precise position.
+
+For one-off actions, prefer the equivalent dedicated method because in most cases they are simpler to use. For example, if you just want to click an element once, use [`userEvent.click`](#userevent-click).
+:::
+
+An action object accepts `keys`, `target`, and `coords`. An object without `keys` only moves the pointer.
+
+The pointer position persists between `userEvent.pointer` calls on the same `userEvent` instance. This means that if an action omits both `target` and `coords`, it uses the position from the previous action.
+
+::: warning
+When the [Browser UI](/config/browser/ui) is enabled, the test iframe can be scaled to fit the available space. Vitest adjusts pointer coordinates to account for the scaling, but browser pixel rounding can cause the resulting event coordinates to differ by several _iframe pixels_. **Avoid relying on exact coordinates in this mode**.
+:::
+
+```ts
+import { page, userEvent } from 'vitest/browser'
+
+test('drags an element with pointer actions', async () => {
+ const source = page.getByTestId('source')
+ const target = page.getByTestId('target')
+
+ await userEvent.pointer([
+ { target: source, keys: '[MouseLeft>]' },
+ { target, coords: { x: 10, y: 10 }, keys: '[/MouseLeft]' },
+ ])
+})
+```
+
+::: warning Provider support and differences from user-event
+This API is not consistently supported across providers, and its behavior can differ from the `user-event` library.
+
+- The `playwright` provider does not support touch pointer keys such as `TouchA`.
+- The `webdriverio` provider does not support this method at all.
+- The `preview` provider simulates events using `@testing-library/user-event`.
+
+Vitest does not support the `node` and `offset` fields used for text selection and only accepts `x` and `y` values in `coords`.
+
+Without `target`, `coords` values are considered coordinates relative to the viewport. With `target`, they are offsets from its top-left corner. Unlike user-event, where coordinates paired with a target describe the final pointer position rather than an offset. When `coords` is provided, an omitted axis defaults to `0`.
+:::
+
+References:
+
+- [testing-library `pointer` API](https://testing-library.com/docs/user-event/pointer)
+
## userEvent.fill
```ts
diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts
index aa9bbe771..7871709ef 100644
--- a/packages/browser-playwright/src/commands/index.ts
+++ b/packages/browser-playwright/src/commands/index.ts
@@ -4,6 +4,7 @@ import { dragAndDrop } from './dragAndDrop'
import { fill } from './fill'
import { hover } from './hover'
import { keyboard, keyboardCleanup } from './keyboard'
+import { pointer } from './pointer'
import { takeScreenshot } from './screenshot'
import { selectOptions } from './select'
import { tab } from './tab'
@@ -28,6 +29,7 @@ export default {
__vitest_dblClick: dblClick as typeof dblClick,
__vitest_tripleClick: tripleClick as typeof tripleClick,
__vitest_wheel: wheel as typeof wheel,
+ __vitest_pointer: pointer as typeof pointer,
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
__vitest_type: type as typeof type,
__vitest_clear: clear as typeof clear,
diff --git a/packages/browser-playwright/src/commands/pointer.ts b/packages/browser-playwright/src/commands/pointer.ts
new file mode 100644
index 000000000..7a52ff5d6
--- /dev/null
+++ b/packages/browser-playwright/src/commands/pointer.ts
@@ -0,0 +1,165 @@
+import type { SerializedLocator } from '@vitest/browser'
+import type { Locator, UserEventPointerInputNormalized } from 'vitest/browser'
+import type { BrowserCommandContext } from 'vitest/node'
+import type { UserEventCommand } from './utils'
+import { parseKeyDef } from '@vitest/browser'
+import { click } from './click'
+import { hover } from './hover'
+
+type SerializedPointerInput = ElementToSerializedLocator
+interface PointerReturnData extends Pick {
+ unreleased: string[]
+}
+type PointerEvent = (
+ input: readonly SerializedPointerInput[],
+ state: Partial,
+) => Promise
+
+type ElementToSerializedLocator = T extends Element | Locator
+ ? SerializedLocator
+ : {
+ [K in keyof T]: ElementToSerializedLocator
+ }
+
+export const pointer: UserEventCommand = async (context, input, state = {}) => {
+ const pressedKeys = new Set(state.unreleased)
+ let lastTarget: SerializedPointerInput['target'] = state.target
+ let lastCoords: SerializedPointerInput['coords'] = state.coords
+
+ for (const option of input) {
+ let target = option.target
+ let coords = option.coords
+
+ if (target || coords) {
+ lastTarget = target
+ lastCoords = coords
+ } else {
+ target = lastTarget
+ coords = lastCoords
+ }
+
+ const pointerAction = { ...option, target, coords }
+ const keys = 'keys' in option ? option.keys : null
+ const parsedKeys = keys === null ? null : groupKeyDefs(parseKeyDef(keys))
+ const hasOnlyClickActions = parsedKeys?.every(
+ ({
+ keyDef: {
+ keyDef: { code },
+ releasePrevious,
+ releaseSelf,
+ },
+ }) => code === 'MouseLeft' && !releasePrevious && releaseSelf,
+ )
+
+ // click has its own moving logic, no need to move twice
+ if (!hasOnlyClickActions) {
+ if (target) {
+ await hover(context, target, {
+ position: coords,
+ })
+ } else if (coords) {
+ await context.page.mouse.move(coords.x, coords.y)
+ }
+ }
+
+ if (parsedKeys) {
+ for (const key of parsedKeys) {
+ await keyDefHandler(key, pointerAction, pressedKeys, context)
+ }
+ }
+ }
+
+ return {
+ unreleased: Array.from(pressedKeys),
+ target: lastTarget,
+ coords: lastCoords,
+ }
+}
+
+type KeyDefOutput = ReturnType[number]
+interface GroupedKeyDef {
+ times: number
+ keyDef: KeyDefOutput
+}
+
+function groupKeyDefs(keyDefs: readonly KeyDefOutput[]): GroupedKeyDef[] {
+ const output: GroupedKeyDef[] = []
+ let last: GroupedKeyDef | undefined
+
+ for (const keyDef of keyDefs) {
+ if (
+ last !== undefined &&
+ // for our purpose we can ignore the `repeat` property
+ last.keyDef.releasePrevious === keyDef.releasePrevious &&
+ last.keyDef.releaseSelf === keyDef.releaseSelf &&
+ last.keyDef.keyDef.key === keyDef.keyDef.key &&
+ last.keyDef.keyDef.code === keyDef.keyDef.code &&
+ last.keyDef.keyDef.location === keyDef.keyDef.location
+ ) {
+ last.times += keyDef.repeat
+ } else {
+ last = { times: keyDef.repeat, keyDef }
+ output.push(last)
+ }
+ }
+
+ return output
+}
+
+const MOUSE_KEYS = ['MouseLeft', 'MouseRight', 'MouseMiddle']
+
+async function keyDefHandler(
+ { keyDef: { keyDef, releasePrevious, releaseSelf }, times }: GroupedKeyDef,
+ pointerAction: Omit,
+ pressedKeys: Set,
+ context: BrowserCommandContext,
+) {
+ const key = keyDef.key!
+ const code = keyDef.code!
+
+ if (MOUSE_KEYS.includes(code)) {
+ const button = code.replace('Mouse', '').toLowerCase() as 'left' | 'right' | 'middle'
+ const mouseOptions = {
+ button,
+ clickCount: times,
+ } satisfies Parameters<(typeof context)['page']['mouse']['up']>[0]
+
+ if (releasePrevious) {
+ await context.page.mouse.up(mouseOptions)
+ } else if (releaseSelf) {
+ if (pointerAction.target) {
+ await click(context, pointerAction.target, {
+ ...mouseOptions,
+ position: pointerAction.coords,
+ })
+ } else {
+ const x = pointerAction.coords?.x ?? 0
+ const y = pointerAction.coords?.y ?? 0
+
+ await context.page.mouse.click(x, y, mouseOptions)
+ }
+ } else {
+ await context.page.mouse.down(mouseOptions)
+ }
+
+ return
+ }
+
+ if (key === 'Unknown') {
+ return
+ }
+
+ if (!releasePrevious) {
+ if (releaseSelf) {
+ for (let count = 0; count < times; count += 1) {
+ await context.page.keyboard.press(key)
+ }
+ } else {
+ await context.page.keyboard.down(key)
+ pressedKeys.add(key)
+ }
+ } else if (pressedKeys.has(key)) {
+ await context.page.keyboard.up(key)
+ pressedKeys.delete(key)
+ }
+}
diff --git a/packages/browser-playwright/src/commands/type.ts b/packages/browser-playwright/src/commands/type.ts
index 18f69d72b..e22058814 100644
--- a/packages/browser-playwright/src/commands/type.ts
+++ b/packages/browser-playwright/src/commands/type.ts
@@ -3,12 +3,9 @@ import type { UserEventCommand } from './utils'
import { keyboardImplementation } from './keyboard'
import { getDescribedLocator } from './utils'
-export const type: UserEventCommand = async (
- context,
- selector,
- text,
- options = {},
-) => {
+export const type: UserEventCommand<
+ (...args: Parameters) => Promise<{ unreleased: string[] }>
+> = async (context, selector, text, options = {}) => {
const { skipClick = false, skipAutoClose = false } = options
const unreleased = new Set((Reflect.get(options, 'unreleased') as string[]) ?? [])
diff --git a/packages/browser-playwright/src/commands/utils.ts b/packages/browser-playwright/src/commands/utils.ts
index c3076f4bf..34bd84437 100644
--- a/packages/browser-playwright/src/commands/utils.ts
+++ b/packages/browser-playwright/src/commands/utils.ts
@@ -3,7 +3,8 @@ import type { Locator } from 'vitest/browser'
import type { BrowserCommand, BrowserCommandContext } from 'vitest/node'
export type UserEventCommand any> = BrowserCommand<
- ConvertUserEventParameters>
+ ConvertUserEventParameters>,
+ ReturnType
>
type ConvertElementToLocator = T extends Element | Locator ? SerializedLocator : T
diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts
index 19aecae07..c50416f17 100644
--- a/packages/browser/context.d.ts
+++ b/packages/browser/context.d.ts
@@ -241,6 +241,33 @@ export interface UserEvent {
* await userEvent.wheel(container, { direction: 'up', times: 5 })
*/
wheel(element: Element | Locator, options: UserEventWheelOptions): Promise
+ /**
+ * Performs one or more pointer actions.
+ *
+ * Pass a string to press or release a pointer button, an object to position the pointer and perform an action, or an array to perform actions in order.
+ * Key strings use the {@link https://testing-library.com/docs/user-event/pointer|`@testing-library/user-event` pointer syntax}.
+ *
+ * Unlike `@testing-library/user-event`, Vitest supports only `x` and `y` coordinates and does not support the `node` and `offset` selection fields.
+ *
+ * @param options - A pointer action or an ordered sequence of pointer actions.
+ * @returns A promise that resolves after all pointer actions finish.
+ *
+ * @since 5.1.0
+ * @experimental The Pointer API is experimental and not subject to semver.
+ * @see {@link https://vitest.dev/api/browser/interactivity#userevent-pointer}
+ *
+ * @example
+ * // Click the left mouse button.
+ * await userEvent.pointer({ target: button, keys: '[MouseLeft]' })
+ *
+ * @example
+ * // Drag from `source` to a point inside `target`.
+ * await userEvent.pointer([
+ * { target: source, keys: '[MouseLeft>]' },
+ * { target, coords: { x: 10, y: 10 }, keys: '[/MouseLeft]' },
+ * ])
+ */
+ pointer(options: UserEventPointerInput): Promise
/**
* Choose one or more values from a select element. Uses provider's API under the hood.
* If select doesn't have `multiple` attribute, only the first value will be selected.
@@ -439,6 +466,62 @@ export interface UserEventWheelDirectionOptions extends UserEventWheelBaseOption
*/
export type UserEventWheelOptions = UserEventWheelDeltaOptions | UserEventWheelDirectionOptions
+/**
+ * Input accepted by {@link UserEvent.pointer}.
+ *
+ * - A string presses or releases a pointer button at the current position.
+ * - An object with `keys` positions the pointer and performs a button action.
+ * - An object without `keys` moves the pointer.
+ * - An array performs its actions in order.
+ *
+ * @since 5.1.0
+ * @experimental
+ */
+export type UserEventPointerInput = PointerActionInput | readonly PointerActionInput[]
+/**
+ * Array form of pointer input after single-entry inputs and strings actions are expanded.
+ *
+ * @internal
+ */
+export type UserEventPointerInputNormalized = readonly (PointerActionInputObject & {
+ coords?: Required
+})[]
+
+type PointerActionInput = string | PointerActionInputObject
+type PointerActionInputObject = PointerActionKeys | PointerMoveAction
+
+interface PointerActionPosition {
+ /**
+ * Element to move the pointer to before performing the action.
+ *
+ * If `coords` is omitted, the browser provider chooses a position within the element.
+ * If both `target` and `coords` are omitted, the action uses the last known pointer position.
+ */
+ target?: Element | Locator
+ /**
+ * Coordinates used to position the pointer.
+ *
+ * Without `target`, the values are viewport coordinates.
+ * With `target`, the values are pixel offsets from the target's top-left corner.
+ *
+ * This differs from `@testing-library/user-event`, where coordinates describe the final pointer position.
+ *
+ * Vitest also only supports `x` and `y`. Other coordinates, such as `clientX`, `offsetX`, `pageX`, and `screenX`, are not supported.
+ */
+ coords?: PointerCoords
+}
+
+interface PointerActionKeys extends PointerActionPosition {
+ keys: string
+}
+
+interface PointerMoveAction extends PointerActionPosition {}
+
+interface PointerCoords {
+ x?: number
+ y?: number
+}
+
export interface LocatorOptions {
/**
* Whether to find an exact match: case-sensitive and whole-string. Defaults to `browser.locators.exact`, which is
diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts
index aefeb4ef6..3b8fc15ee 100644
--- a/packages/browser/src/client/tester/context.ts
+++ b/packages/browser/src/client/tester/context.ts
@@ -9,8 +9,10 @@ import type {
Locator,
LocatorSelectors,
MarkOptions,
+ SerializedLocator,
UserEvent,
- UserEventWheelOptions,
+ UserEventPointerInput,
+ UserEventPointerInputNormalized,
} from 'vitest/browser'
import type { StringifyOptions } from 'vitest/internal/browser'
import type { IframeViewportEvent } from '../client'
@@ -21,11 +23,19 @@ import { vi } from 'vitest'
import { __INTERNAL, stringify } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
import { ScreenshotAction } from './action'
-import { isLocator, resolveUserEventWheelOptions, serializeElement } from './tester-utils'
+import {
+ getIframeOffset,
+ getIframeScale,
+ isLocator,
+ resolveUserEventWheelOptions,
+ serializeElement,
+} from './tester-utils'
import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace'
// this file should not import anything directly, only types and utils
+type PointerState = Pick
+
// @ts-expect-error not typed global
const provider = __vitest_browser_runner__.provider
const sessionId = getBrowserState().sessionId
@@ -46,6 +56,10 @@ export function createUserEvent(
const keyboard = {
unreleased: [] as string[],
}
+ const pointerState: PointerState = {
+ coords: undefined,
+ target: undefined,
+ }
// https://playwright.dev/docs/api/class-keyboard
// https://webdriver.io/docs/api/browser/keys/
@@ -75,9 +89,54 @@ export function createUserEvent(
tripleClick(element, options) {
return convertToLocator(element).tripleClick(options)
},
- wheel(elementOrOptions: Element | Locator, options: UserEventWheelOptions) {
+ wheel(elementOrOptions, options) {
return convertToLocator(elementOrOptions).wheel(options)
},
+ pointer(input) {
+ return ensureAwaited(async () => {
+ type SerializedInput = UserEventPointerInputNormalized[number] extends infer PIN
+ ? { [K in keyof PIN]: K extends 'target' ? SerializedLocator : PIN[K] }
+ : never
+
+ const scale = getIframeScale()
+ const offset = getIframeOffset()
+ const inputArray = (Array.isArray(input) ? input : [input]) as Extract<
+ UserEventPointerInput,
+ readonly any[]
+ >
+ const serializedInputArray = await Promise.all(
+ inputArray.map>(async (input) => {
+ if (typeof input === 'string') {
+ return {
+ keys: input,
+ }
+ }
+
+ return {
+ ...input,
+ target: input.target && (await serializeElement(input.target)),
+ coords: input.coords && {
+ x: input.coords.x ? input.coords.x * scale + (input.target ? 0 : offset.x) : 0,
+ y: input.coords.y ? input.coords.y * scale + (input.target ? 0 : offset.y) : 0,
+ },
+ }
+ }),
+ )
+
+ const { coords, target, unreleased } = await triggerCommand<
+ PointerState & {
+ unreleased: string[]
+ }
+ >('__vitest_pointer', [
+ serializedInputArray,
+ { ...pointerState, unreleased: keyboard.unreleased },
+ ])
+
+ pointerState.target = target
+ pointerState.coords = coords
+ keyboard.unreleased = unreleased
+ })
+ },
selectOptions(element, value, options) {
return convertToLocator(element).selectOptions(value, options)
},
@@ -245,7 +304,7 @@ function createPreviewUserEvent(
async paste() {
await userEvent.paste(clipboardData)
},
- async wheel(element: Element | Locator, options: UserEventWheelOptions) {
+ async wheel(element, options) {
const resolvedElement = isLocator(element) ? element.element() : element
const resolvedOptions = resolveUserEventWheelOptions(options)
@@ -270,6 +329,42 @@ function createPreviewUserEvent(
resolvedElement.dispatchEvent(wheelEvent)
}
},
+ async pointer(input) {
+ type SerializedInput = UserEventPointerInputNormalized[number] extends infer PIN
+ ? { [K in keyof PIN]: K extends 'target' ? Element : PIN[K] }
+ : never
+
+ const inputArray = (Array.isArray(input) ? input : [input]) as Extract<
+ UserEventPointerInput,
+ readonly any[]
+ >
+ const normalizedInput = inputArray.map((input) => {
+ if (typeof input === 'string') {
+ return { keys: input } satisfies SerializedInput
+ }
+
+ const target = input.target
+
+ return {
+ ...input,
+ get target() {
+ const value = isLocator(target) ? target.element() : target
+
+ Reflect.defineProperty(this, 'target', {
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ value,
+ })
+
+ return value
+ },
+ coords: input.coords && { x: input.coords.x ?? 0, y: input.coords.y ?? 0 },
+ }
+ })
+
+ await userEvent.pointer(normalizedInput)
+ },
}
for (const [name, fn] of Object.entries(vitestUserEvent)) {
diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts
index d207242b3..402272575 100644
--- a/packages/browser/src/client/tester/tester-utils.ts
+++ b/packages/browser/src/client/tester/tester-utils.ts
@@ -121,6 +121,7 @@ const ACTION_TRACE_COMMANDS = new Set([
'__vitest_tab',
'__vitest_keyboard',
'__vitest_takeScreenshot',
+ '__vitest_pointer',
])
export class CommandsManager {
@@ -225,7 +226,7 @@ export class CommandsManager {
}
}
-export function getIframeScale(): number {
+function getIframe() {
const iframe = window.frameElement
if (!iframe) {
@@ -234,6 +235,11 @@ export function getIframeScale(): number {
)
}
+ return iframe as HTMLIFrameElement
+}
+
+export function getIframeScale(): number {
+ const iframe = getIframe()
// DOMMatrix parses the computed 2D transform matrix [a, b, c, d, e, f]
// `a` and `d` are the x and y scale factors - since we only apply uniform scaling, `a === d`
const scale = new DOMMatrix(getComputedStyle(iframe).transform).a
@@ -241,6 +247,12 @@ export function getIframeScale(): number {
return scale
}
+export function getIframeOffset(): { x: number; y: number } {
+ const iframe = getIframe()
+
+ return iframe.getBoundingClientRect()
+}
+
function escapeRegexForSelector(re: RegExp): string {
// Unicode mode does not allow "identity character escapes", so we do not escape and
// hope that it does not contain quotes and/or >> signs.
diff --git a/test/browser/fixtures/user-event/pointer.test.ts b/test/browser/fixtures/user-event/pointer.test.ts
new file mode 100644
index 000000000..38fcef4d5
--- /dev/null
+++ b/test/browser/fixtures/user-event/pointer.test.ts
@@ -0,0 +1,598 @@
+import { beforeAll, describe, expect, test, vi } from 'vitest'
+import { userEvent, page, server } from 'vitest/browser'
+
+type PointerAction = (event: PointerEvent) => void
+type MouseAction = (event: MouseEvent) => void
+
+declare module 'vitest' {
+ interface Matchers {
+ __withinTolerance(expected: number): R
+ }
+}
+
+// when UI is enabled, automatically set the viewport using this value as the scale target
+const IFRAME_SCALE_TARGET = 0.8
+
+beforeAll(async () => {
+ if (server.config.browser.ui) {
+ const frame = window.frameElement as HTMLIFrameElement
+ const container = frame.parentElement!
+ const frameWidth = frame.getBoundingClientRect().width
+ const containerWidth = container.getBoundingClientRect().width
+
+ await page.viewport(
+ Math.round(Math.max(containerWidth, frameWidth) * (1 / IFRAME_SCALE_TARGET)),
+ window.innerHeight,
+ )
+ }
+})
+
+// when running with UI enabled, because of scaling, a real "browser pixel"
+// maps to many "iframe pixels"
+expect.extend({
+ __withinTolerance(received, expected) {
+ const difference = Math.abs(received - expected)
+ const tolerance = server.config.browser.ui
+ // the test is running in a scaled frame, compute how many "iframe pixels" correspond
+ // to a "browser pixel", ceil the result, and add an extra buffer for safety
+ ? Math.ceil(1 / IFRAME_SCALE_TARGET)
+ // the test is running at full scale so the provided coordinates should match exactly
+ : 0
+
+ return {
+ pass: difference <= tolerance,
+ message: () =>
+ `expected ${received} to be within ±${tolerance} of ${expected}`,
+ actual: received,
+ expected,
+ }
+ },
+})
+
+const BUTTON_WIDTH = 100
+const BUTTON_HEIGHT = 40
+const BUTTON_OFFSET = 10
+const BUTTON_CLICK_COORDS = 15
+const BUTTON_ABSOLUTE_HTML = /* html */``
+
+// debugging utility to
+// - log event position and offset (w.r.t. target)
+// - print a red circle on `mousedown` and a blue one on `mouseup`
+// beforeAll(() => {
+// const id = 'pntr'
+// const ac = new AbortController()
+// const fn = (color: string) => (e: MouseEvent) => {
+// console.log(e)
+// console.log({
+// browser: server.config.browser.name,
+// clientX: e.clientX,
+// clientY: e.clientY,
+// offsetX: e.offsetX,
+// offsetY: e.offsetY,
+// })
+// let pointer = document.getElementById(id)
+// if (!pointer) {
+// pointer = document.createElement('div')
+// pointer.id = id
+// document.body.appendChild(pointer)
+// }
+// pointer.style = `position: absolute; top: ${e.clientY}px; left: ${e.clientX}px; width: 5px; height: 5px; background-color: ${color}; pointer-events: none; border-radius: 100%;`
+// }
+// document.addEventListener('mousedown', fn('red'), { signal: ac.signal })
+// document.addEventListener('mouseup', fn('blue'), { signal: ac.signal })
+// return () => {
+// ac.abort()
+// }
+// })
+
+test('click triggers hover events', async ({ expect }) => {
+ document.body.innerHTML = /* html */`
+
+
+
+ `;
+
+ const enter = vi.fn()
+ const leave = vi.fn()
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('mouseenter', enter)
+ buttonElement.addEventListener('mouseleave', leave)
+ buttonElement.addEventListener('click', click)
+
+ const target = page.getByRole("button")
+
+ await userEvent.pointer([
+ { target, keys: '[MouseLeft]' },
+ { target: document.body },
+ ])
+
+ expect(enter).toHaveBeenCalledOnce()
+ expect(click).toHaveBeenCalledOnce()
+ expect(leave).toHaveBeenCalledOnce()
+
+ expect(enter).toHaveBeenCalledBefore(click)
+ expect(click).toHaveBeenCalledBefore(leave)
+})
+
+test('click at coordinates triggers hover events', async ({ expect }) => {
+ document.body.innerHTML = BUTTON_ABSOLUTE_HTML
+
+ const enter = vi.fn()
+ const leave = vi.fn()
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('mouseenter', enter)
+ buttonElement.addEventListener('mouseleave', leave)
+ buttonElement.addEventListener('click', click)
+
+ await userEvent.pointer([
+ { coords: { x: BUTTON_CLICK_COORDS, y: BUTTON_CLICK_COORDS }, keys: '[MouseLeft]' },
+ { target: document.body },
+ ])
+
+ expect(enter).toHaveBeenCalledOnce()
+ expect(click).toHaveBeenCalledOnce()
+ expect(click).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ clientX: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ clientY: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ }))
+
+ expect(enter).toHaveBeenCalledBefore(click)
+ expect(click).toHaveBeenCalledBefore(leave)
+})
+
+test('moves between coordinates', async ({ expect }) => {
+ document.body.innerHTML = /* html */`
+
+
+ `
+
+ const enterA = vi.fn()
+ const leaveA = vi.fn()
+ const enterB = vi.fn()
+
+ const a = document.body.querySelector('#a') as HTMLDivElement
+ const b = document.body.querySelector('#b') as HTMLDivElement
+
+ a.addEventListener('mouseenter', enterA)
+ a.addEventListener('mouseleave', leaveA)
+ b.addEventListener('mouseenter', enterB)
+
+ await userEvent.pointer([
+ { coords: { x: 50, y: 50 } },
+ { coords: { x: 50, y: 250 } },
+ ])
+
+ expect(enterA).toHaveBeenCalledOnce()
+ expect(leaveA).toHaveBeenCalledOnce()
+ expect(enterB).toHaveBeenCalledOnce()
+ expect(enterA).toHaveBeenCalledBefore(leaveA)
+ expect(leaveA).toHaveBeenCalledBefore(enterB)
+})
+
+test('down only fires mousedown event', async ({ expect }) => {
+ document.body.innerHTML = /* html */``
+
+ const down = vi.fn()
+ const up = vi.fn()
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('mousedown', down)
+ buttonElement.addEventListener('mouseup', up)
+ buttonElement.addEventListener('click', click)
+
+ const target = page.getByRole('button')
+
+ await userEvent.pointer([
+ { target, keys: '[MouseLeft>]' },
+ ])
+
+ expect(down).toHaveBeenCalledOnce()
+ expect(up).not.toHaveBeenCalled()
+ expect(click).not.toHaveBeenCalled()
+})
+
+test.for([
+ { action: 'down', keys: '[MouseLeft>]' },
+ { action: 'up', keys: '[/MouseLeft]' },
+ { action: 'click', keys: '[MouseLeft]' },
+] as const)('pointer $action action works with offsets', async ({ action, keys }, { expect }) => {
+ document.body.innerHTML = BUTTON_ABSOLUTE_HTML
+
+ const spy = vi.fn<(e: PointerEvent | MouseEvent) => void>()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener(action === 'click' ? 'click' : `mouse${action}`, spy)
+
+ await userEvent.pointer([
+ { target: buttonElement, coords: { x: BUTTON_CLICK_COORDS, y: BUTTON_CLICK_COORDS }, keys },
+ ])
+
+ expect(spy).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ clientX: expect.__withinTolerance(BUTTON_CLICK_COORDS + BUTTON_OFFSET),
+ clientY: expect.__withinTolerance(BUTTON_CLICK_COORDS + BUTTON_OFFSET),
+ offsetX: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ offsetY: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ }))
+})
+
+test('multiple clicks trigger double click', async ({ expect }) => {
+ document.body.innerHTML = /* html */``
+
+ const click = vi.fn()
+ const doubleClick = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('click', click)
+ buttonElement.addEventListener('dblclick', doubleClick)
+
+ const target = page.getByRole('button')
+
+ await userEvent.pointer([
+ { target, keys: '[MouseLeft]'.repeat(3) },
+ ])
+
+ expect(click).toHaveBeenCalledTimes(3)
+
+ expect(click).toHaveBeenNthCalledWith(1, expect.objectContaining({ detail: 1 }))
+ expect(click).toHaveBeenNthCalledWith(2, expect.objectContaining({ detail: 2 }))
+ expect(click).toHaveBeenNthCalledWith(3, expect.objectContaining({ detail: 3 }))
+
+ expect(doubleClick).toHaveBeenCalledOnce()
+})
+
+test('clicks with middle button', async ({ expect }) => {
+ document.body.innerHTML = /* html */``
+
+ const down = vi.fn()
+ const up = vi.fn()
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('mousedown', down)
+ buttonElement.addEventListener('mouseup', up)
+ buttonElement.addEventListener('click', click)
+
+ const target = page.getByRole('button')
+
+ await userEvent.pointer([
+ { target, keys: '[MouseMiddle]' },
+ ])
+
+ expect(down).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ button: 1,
+ }))
+ expect(up).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ button: 1,
+ }))
+ expect(click).not.toHaveBeenCalled()
+})
+
+test('clicks with right button', async ({ expect }) => {
+ document.body.innerHTML = /* html */``
+
+ const down = vi.fn()
+ const up = vi.fn()
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('mousedown', down)
+ buttonElement.addEventListener('mouseup', up)
+ buttonElement.addEventListener('click', click)
+ document.addEventListener('contextmenu', e => e.preventDefault());
+
+ const target = page.getByRole('button')
+
+ await userEvent.pointer([
+ { target, keys: '[MouseRight]' },
+ ])
+
+ expect(down).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ button: 2,
+ }))
+ expect(up).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ button: 2,
+ }))
+ expect(click).not.toHaveBeenCalled()
+})
+
+test('drags and drops', async ({ expect }) => {
+ document.body.innerHTML = /* html */`
+
+ Drop here
+ `
+
+ const DRAG_CONTENT = 'drag content'
+
+ const source = document.body.querySelector('#source') as HTMLElement
+ const dropTarget = document.body.querySelector('#target') as HTMLElement
+
+ type DragAction = (event: DragEvent) => void
+
+ let dragData: string | undefined
+
+ const dragStart = vi.fn((event) => {
+ event.dataTransfer!.setData('text/plain', DRAG_CONTENT)
+ })
+ const dragEnter = vi.fn()
+ const drop = vi.fn((event) => {
+ // retrieving the data from the mock doesn't work, so we need to save it
+ dragData = event.dataTransfer!.getData('text/plain')
+ })
+ const dragEnd = vi.fn()
+
+ source.addEventListener('dragstart', dragStart)
+ dropTarget.addEventListener('dragenter', dragEnter)
+ dropTarget.addEventListener('dragover', (event) => event.preventDefault())
+ dropTarget.addEventListener('drop', drop)
+ source.addEventListener('dragend', dragEnd)
+
+ await userEvent.pointer([
+ { target: source, keys: '[MouseLeft>]' },
+ { target: dropTarget, keys: '[/MouseLeft]' },
+ ])
+
+ expect(dragStart).toHaveBeenCalledOnce()
+ expect(dragEnter).toHaveBeenCalledOnce()
+ expect(drop).toHaveBeenCalledOnce()
+ expect(dragEnd).toHaveBeenCalledOnce()
+ expect(dragStart).toHaveBeenCalledBefore(dragEnter)
+ expect(dragEnter).toHaveBeenCalledBefore(drop)
+ expect(drop).toHaveBeenCalledBefore(dragEnd)
+
+ expect(dragData).toBe(DRAG_CONTENT)
+})
+
+test('temporary modifiers apply to one action', async ({ expect }) => {
+ document.body.innerHTML = /* html */``
+
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('click', click)
+
+ const target = page.getByRole('button')
+
+ await userEvent.pointer([
+ { target, keys: '[ShiftLeft>][MouseLeft][/ShiftLeft]' },
+ { target, keys: '[MouseLeft]' },
+ ])
+
+ expect(click).toHaveBeenCalledTimes(2)
+ expect(click).toHaveBeenNthCalledWith(1, expect.objectContaining({ shiftKey: true }))
+ expect(click).toHaveBeenNthCalledWith(2, expect.objectContaining({ shiftKey: false }))
+})
+
+test('persistent modifiers survive multiple actions', async ({ expect }) => {
+ document.body.innerHTML = /* html */`
+
+
+
+
+ `
+
+ const clickA = vi.fn()
+ const clickB = vi.fn()
+ const clickC = vi.fn()
+ const clickD = vi.fn()
+
+ const a = document.body.querySelector('#a') as HTMLButtonElement
+ const b = document.body.querySelector('#b') as HTMLButtonElement
+ const c = document.body.querySelector('#c') as HTMLButtonElement
+ const d = document.body.querySelector('#d') as HTMLButtonElement
+
+ a.addEventListener('click', clickA)
+ b.addEventListener('click', clickB)
+ c.addEventListener('click', clickC)
+ d.addEventListener('click', clickD)
+
+ await userEvent.pointer([
+ { target: a, keys: '[ShiftLeft>][AltLeft>][MouseLeft]' },
+ { target: b, keys: '[MouseLeft]' },
+ { target: c, keys: '[MouseLeft][/ShiftLeft][/AltLeft]' },
+ { target: d, keys: '[MouseLeft]' },
+ ])
+
+ expect(clickA).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ shiftKey: true, altKey: true }))
+ expect(clickB).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ shiftKey: true, altKey: true }))
+ expect(clickC).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ shiftKey: true, altKey: true }))
+ expect(clickD).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ shiftKey: false, altKey: false }))
+})
+
+test('modifiers work with coordinates', async ({ expect }) => {
+ document.body.innerHTML = BUTTON_ABSOLUTE_HTML
+
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('click', click)
+
+ await userEvent.pointer([
+ {
+ coords: { x: BUTTON_CLICK_COORDS, y: BUTTON_CLICK_COORDS },
+ keys: '[AltLeft>][MouseLeft][/AltLeft]',
+ },
+ ])
+
+ expect(click).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ altKey: true,
+ clientX: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ clientY: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ }))
+})
+
+test('keyboard-fired modifiers apply to pointer events', async ({ expect }) => {
+ document.body.innerHTML = /* html */``
+
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+
+ buttonElement.addEventListener('click', click)
+
+ const target = page.getByRole('button')
+
+ await userEvent.keyboard('[ShiftLeft>]')
+ await userEvent.pointer({ keys: '[MouseLeft][/ShiftLeft]', target })
+ await userEvent.keyboard('[AltLeft>]')
+ await userEvent.pointer({ keys: '[MouseLeft]', target })
+ await userEvent.keyboard('[/AltLeft][MetaLeft>]')
+ await userEvent.pointer({ keys: '[MouseLeft]', target })
+ await userEvent.keyboard('[/MetaLeft]')
+
+ expect(click).toHaveBeenNthCalledWith(1, expect.objectContaining({ shiftKey: true, altKey: false, metaKey: false }))
+ expect(click).toHaveBeenNthCalledWith(2, expect.objectContaining({ altKey: true, shiftKey: false, metaKey: false }))
+ expect(click).toHaveBeenNthCalledWith(3, expect.objectContaining({ metaKey: true, shiftKey: false, altKey: false }))
+})
+
+describe('keeps using previous target or coordinates', () => {
+ const TARGET = page.getByRole('button')
+ const COORDS = { x: BUTTON_CLICK_COORDS, y: BUTTON_CLICK_COORDS }
+ const EXPECTED_COORDS_TARGET = {
+ clientX: expect.__withinTolerance(BUTTON_WIDTH / 2 + BUTTON_OFFSET),
+ clientY: expect.__withinTolerance(BUTTON_HEIGHT / 2 + BUTTON_OFFSET),
+ }
+ const EXPECTED_COORDS_COORDS = {
+ clientX: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ clientY: expect.__withinTolerance(BUTTON_CLICK_COORDS),
+ }
+
+ test.for([
+ {
+ name: 'target same',
+ action: async () => {
+ await userEvent.pointer([
+ { keys: '[MouseLeft]', target: TARGET },
+ '[MouseLeft]',
+ ])
+ },
+ expectedCoords: EXPECTED_COORDS_TARGET,
+ },
+ {
+ name: 'target different',
+ action: async () => {
+ await userEvent.pointer([{ keys: '[MouseLeft]', target: TARGET }])
+ await userEvent.pointer('[MouseLeft]',)
+ },
+ expectedCoords: EXPECTED_COORDS_TARGET,
+ },
+ {
+ name: 'coordinates same',
+ action: async () => {
+ await userEvent.pointer([
+ { keys: '[MouseLeft]', coords: COORDS },
+ '[MouseLeft]',
+ ])
+ },
+ expectedCoords: EXPECTED_COORDS_COORDS,
+ },
+ {
+ name: 'coordinates different',
+ action: async () => {
+ await userEvent.pointer([{ keys: '[MouseLeft]', coords: COORDS }])
+ await userEvent.pointer('[MouseLeft]',)
+ },
+ expectedCoords: EXPECTED_COORDS_COORDS,
+ },
+ ])('previous $name action', async ({ action, expectedCoords }, { expect }) => {
+ document.body.innerHTML = BUTTON_ABSOLUTE_HTML
+
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+ buttonElement.addEventListener('click', click)
+
+ await action()
+
+ expect(click).toHaveBeenCalledTimes(2)
+ expect(click).toHaveBeenNthCalledWith(1, expect.objectContaining(expectedCoords))
+ expect(click).toHaveBeenNthCalledWith(2, expect.objectContaining(expectedCoords))
+ })
+
+ test.for([
+ {
+ name: 'coords resets previous target',
+ action: async () => {
+ await userEvent.pointer([
+ { keys: '[MouseLeft]', target: TARGET },
+ '[MouseLeft]',
+ { keys: '[MouseLeft]', coords: COORDS },
+ ])
+ },
+ expectedCalls: [
+ [expect.objectContaining(EXPECTED_COORDS_TARGET)],
+ [expect.objectContaining(EXPECTED_COORDS_TARGET)],
+ [expect.objectContaining(EXPECTED_COORDS_COORDS)],
+ ]
+ },
+ {
+ name: 'target resets previous coords',
+ action: async () => {
+ await userEvent.pointer([
+ { keys: '[MouseLeft]', coords: COORDS },
+ '[MouseLeft]',
+ { keys: '[MouseLeft]', target: TARGET },
+ ])
+ },
+ expectedCalls: [
+ [expect.objectContaining(EXPECTED_COORDS_COORDS)],
+ [expect.objectContaining(EXPECTED_COORDS_COORDS)],
+ [expect.objectContaining(EXPECTED_COORDS_TARGET)],
+ ]
+ },
+ ])('$name', async ({ action, expectedCalls }, { expect }) => {
+ document.body.innerHTML = BUTTON_ABSOLUTE_HTML
+
+ const click = vi.fn()
+
+ const buttonElement = document.body.querySelector('button')!
+ buttonElement.addEventListener('click', click)
+
+ await action()
+
+ expect(click).toHaveBeenCalledTimes(3)
+ expect(click.mock.calls).toEqual(expectedCalls)
+ })
+
+ test('reuses previous target even after other user events', async ({ expect }) => {
+ document.body.innerHTML = /* html */`
+
+
+ `
+
+ const aButton = document.getElementById('a')!
+ const bButton = document.getElementById('b')!
+ const aSpy = vi.fn()
+ const bSpy = vi.fn()
+
+ aButton.addEventListener('click', aSpy)
+ bButton.addEventListener('click', bSpy)
+
+ await userEvent.pointer({ target: aButton, keys: '[MouseLeft]' })
+
+ expect(aSpy).toHaveBeenCalledOnce()
+
+ await userEvent.click(bButton)
+
+ expect(aSpy).toHaveBeenCalledOnce()
+ expect(bSpy).toHaveBeenCalledOnce()
+
+ await userEvent.pointer('[MouseLeft]')
+
+ expect(aSpy).toHaveBeenCalledTimes(2)
+ expect(bSpy).toHaveBeenCalledOnce()
+ })
+})
diff --git a/test/browser/specs/pointer.test.ts b/test/browser/specs/pointer.test.ts
new file mode 100644
index 000000000..d0f5505cd
--- /dev/null
+++ b/test/browser/specs/pointer.test.ts
@@ -0,0 +1,116 @@
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { expect, test } from 'vitest'
+import { runInlineTests } from '../../test-utils'
+import { instances, provider } from '../settings'
+
+test('pointer API works with UI enabled', async () => {
+ const { errorTree } = await runInlineTests(
+ {
+ 'pointer.test.ts': await readFile(
+ resolve(import.meta.dirname, '..', 'fixtures/user-event/pointer.test.ts'),
+ { encoding: 'utf8' },
+ ),
+ },
+ {
+ browser: {
+ enabled: true,
+ headless: true,
+ ui: true,
+ provider,
+ instances,
+ },
+ // might be flaky because of 1:MANY pixel scaling mapping
+ retry: 3,
+ },
+ )
+
+ expect(errorTree({ project: true })).toMatchInlineSnapshot(`
+ {
+ "chromium": {
+ "pointer.test.ts": {
+ "click at coordinates triggers hover events": "passed",
+ "click triggers hover events": "passed",
+ "clicks with middle button": "passed",
+ "clicks with right button": "passed",
+ "down only fires mousedown event": "passed",
+ "drags and drops": "passed",
+ "keeps using previous target or coordinates": {
+ "coords resets previous target": "passed",
+ "previous coordinates different action": "passed",
+ "previous coordinates same action": "passed",
+ "previous target different action": "passed",
+ "previous target same action": "passed",
+ "reuses previous target even after other user events": "passed",
+ "target resets previous coords": "passed",
+ },
+ "keyboard-fired modifiers apply to pointer events": "passed",
+ "modifiers work with coordinates": "passed",
+ "moves between coordinates": "passed",
+ "multiple clicks trigger double click": "passed",
+ "persistent modifiers survive multiple actions": "passed",
+ "pointer click action works with offsets": "passed",
+ "pointer down action works with offsets": "passed",
+ "pointer up action works with offsets": "passed",
+ "temporary modifiers apply to one action": "passed",
+ },
+ },
+ "firefox": {
+ "pointer.test.ts": {
+ "click at coordinates triggers hover events": "passed",
+ "click triggers hover events": "passed",
+ "clicks with middle button": "passed",
+ "clicks with right button": "passed",
+ "down only fires mousedown event": "passed",
+ "drags and drops": "passed",
+ "keeps using previous target or coordinates": {
+ "coords resets previous target": "passed",
+ "previous coordinates different action": "passed",
+ "previous coordinates same action": "passed",
+ "previous target different action": "passed",
+ "previous target same action": "passed",
+ "reuses previous target even after other user events": "passed",
+ "target resets previous coords": "passed",
+ },
+ "keyboard-fired modifiers apply to pointer events": "passed",
+ "modifiers work with coordinates": "passed",
+ "moves between coordinates": "passed",
+ "multiple clicks trigger double click": "passed",
+ "persistent modifiers survive multiple actions": "passed",
+ "pointer click action works with offsets": "passed",
+ "pointer down action works with offsets": "passed",
+ "pointer up action works with offsets": "passed",
+ "temporary modifiers apply to one action": "passed",
+ },
+ },
+ "webkit": {
+ "pointer.test.ts": {
+ "click at coordinates triggers hover events": "passed",
+ "click triggers hover events": "passed",
+ "clicks with middle button": "passed",
+ "clicks with right button": "passed",
+ "down only fires mousedown event": "passed",
+ "drags and drops": "passed",
+ "keeps using previous target or coordinates": {
+ "coords resets previous target": "passed",
+ "previous coordinates different action": "passed",
+ "previous coordinates same action": "passed",
+ "previous target different action": "passed",
+ "previous target same action": "passed",
+ "reuses previous target even after other user events": "passed",
+ "target resets previous coords": "passed",
+ },
+ "keyboard-fired modifiers apply to pointer events": "passed",
+ "modifiers work with coordinates": "passed",
+ "moves between coordinates": "passed",
+ "multiple clicks trigger double click": "passed",
+ "persistent modifiers survive multiple actions": "passed",
+ "pointer click action works with offsets": "passed",
+ "pointer down action works with offsets": "passed",
+ "pointer up action works with offsets": "passed",
+ "temporary modifiers apply to one action": "passed",
+ },
+ },
+ }
+ `)
+})