From 5601afba4a7223b7fb5d1741ebd6cea090cac4d0 Mon Sep 17 00:00:00 2001 From: Raul Macarie Date: Mon, 24 Aug 2026 09:32:44 +0200 Subject: [PATCH] feat: user-event API --- .../src/commands/pointer.ts | 200 ++++++++++++------ packages/browser/context.d.ts | 86 ++++---- packages/browser/src/client/tester/context.ts | 25 ++- .../fixtures/user-event/pointer.test.ts | 47 ++-- 4 files changed, 211 insertions(+), 147 deletions(-) diff --git a/packages/browser-playwright/src/commands/pointer.ts b/packages/browser-playwright/src/commands/pointer.ts index 4eabce266..3283460a3 100644 --- a/packages/browser-playwright/src/commands/pointer.ts +++ b/packages/browser-playwright/src/commands/pointer.ts @@ -1,12 +1,27 @@ import type { SerializedLocator } from '@vitest/browser' -import type { Locator, UserEventPointerOptions } from 'vitest/browser' +import type { Locator, PointerInputNormalized } from 'vitest/browser' +import type { BrowserCommandContext } from 'vitest/node' import type { UserEventCommand } from './utils' +import { deepEqual } from 'node:assert/strict' import { parseKeyDef } from '@vitest/browser' import { click } from './click' import { hover } from './hover' +// @todo remove this abomination +function equals(a: object, b: object): boolean { + try { + deepEqual(a, b) + + return true + } + catch { + return false + } +} + +type SerializedPointerInput = ElementToSerializedLocator type PointerEvent = ( - options: readonly ElementToSerializedLocator[], + input: readonly SerializedPointerInput[], ) => Promise type ElementToSerializedLocator = T extends Element | Locator @@ -17,85 +32,138 @@ type ElementToSerializedLocator = T extends Element | Locator export const pointer: UserEventCommand = async ( context, - options, + input, ) => { // @todo: should this throw if keys are not released at the end? const pressedKeys = new Set() - for (const option of options) { - const keys = option.keys === undefined ? null : parseKeyDef(option.keys) - const keysToRelease = new Set() - - if (keys) { - for (const { keyDef, releaseSelf, releasePrevious } of keys) { - const key = keyDef.key! - - if (!releasePrevious) { - await context.page.keyboard.down(key) - } - else if (pressedKeys.has(key)) { - keysToRelease.add(key) - pressedKeys.delete(key) - } - - if (releaseSelf) { - keysToRelease.add(key) - } - else { - pressedKeys.add(key) - } - } - } + // @todo save lastTarget or lastCoords for clicking when there's no target or coords otherwise we can't perform the action + for (const option of input) { + const keys = 'keys' in option + ? option.keys + : null + const parsedKeys = keys === null ? null : groupKeyDefs(parseKeyDef(keys)) + const hasMouseButtonAction = parsedKeys?.some( + ({ keyDef: { keyDef: { code }, releasePrevious, releaseSelf } }) => code === 'MouseLeft' && !releasePrevious && releaseSelf, + ) + + // mouse buttons have their own moving logic, no need to move twice + if (!hasMouseButtonAction) { + const x = option.coords?.x ?? 0 + const y = option.coords?.y ?? 0 - // `click` has its own moving logic, no need to move twice - if (option.action !== 'click') { if (option.target) { - await hover(context, option.target, { position: option.offset }) + await hover( + context, + option.target, + { position: option.coords ? { x, y } : undefined }, + ) } - else { - await context.page.mouse.move(option.coordinates.x, option.coordinates.y) + else if (option.coords) { + await context.page.mouse.move(x, y) } } - if (option.action) { - const mouseOptions = { - button: option.button, + // console.log('parsedKeys', parsedKeys) + + if (parsedKeys) { + for (const key of parsedKeys) { + await keyDefHandler(key, option, pressedKeys, context) } + } + } +} + +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 && equals(last.keyDef, keyDef)) { + last.times += keyDef.repeat + } + else { + last = { times: 1, 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! - switch (option.action) { - case 'down': { - await context.page.mouse.down(mouseOptions) - break - } - case 'up': { - await context.page.mouse.up(mouseOptions) - break - } - case 'click': { - const clickOptions = { - ...mouseOptions, - clickCount: option.times ?? 1, - position: option.offset, - } satisfies Parameters[2] - - if (option.target) { - await click(context, option.target, clickOptions) - } - else { - await context.page.mouse.click( - option.coordinates.x, - option.coordinates.y, - clickOptions, - ) - } - - break - } + if (MOUSE_KEYS.includes(code)) { + const button = code.replace('Mouse', '').toLowerCase() as 'left' | 'right' | 'middle' + const mouseOptions = { + button, + } + + if (releasePrevious) { + await context.page.mouse.up(mouseOptions) + } + else if (releaseSelf) { + const clickOptions = { + ...mouseOptions, + clickCount: times, + position: pointerAction.coords + ? { + x: pointerAction.coords?.x ?? 0, + y: pointerAction.coords?.y ?? 0, + } + : undefined, + } satisfies Parameters[2] + + if (pointerAction.target) { + await click(context, pointerAction.target, clickOptions) } + else { + await context.page.mouse.click( + pointerAction.coords?.x ?? 0, + pointerAction.coords?.y ?? 0, + clickOptions, + ) + } + } + else { + await context.page.mouse.down(mouseOptions) } - for (const key of keysToRelease) { - await context.page.keyboard.up(key) + 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/context.d.ts b/packages/browser/context.d.ts index 4b4201e1b..fe3c76abe 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -254,7 +254,7 @@ export interface UserEvent { /** * @todo */ - pointer(options: readonly UserEventPointerOptions[]): Promise + pointer(options: PointerInput): 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. @@ -445,55 +445,41 @@ export interface UserEventWheelDirectionOptions extends UserEventWheelBaseOption */ export type UserEventWheelOptions = UserEventWheelDeltaOptions | UserEventWheelDirectionOptions -/** - * Options for triggering pointer events. - * - * Specify pointer position using either `coordinates` for precise pixel values, or `target` for element-based interaction. These are mutually exclusive. - * - * @since 5.0.0 - */ -export type UserEventPointerOptions = { - /** - * The keys to press while interacting with the pointer. - * - * @default undefined - */ - keys?: string - /** - * The button to use for the pointer event. - * - * @default 'left' - */ - button?: 'left' | 'right' | 'middle' -} & ({ - /** - * The action to perform with the pointer event. - * - * @default undefined - */ - action?: 'down' | 'up' - times?: undefined -} | { - action: 'click' - times?: number -}) & ({ - /** - * The coordinates to interact with. - */ - coordinates: { x: number; y: number } - offset?: undefined - target?: undefined -} | { - coordinates?: undefined - /** - * A point to use relative to the top-left corner of the element's padding box. If not specified, uses some visible point of the element. - */ - offset?: { x: number; y: number } - /** - * The target element to interact with. - */ - target: Element | Locator -}) +export type PointerInput = PointerActionInput | readonly PointerActionInput[]; +export type PointerInputNormalized = readonly PointerActionInputObject[] + +type PointerActionInput = string | PointerActionInputObject +type PointerActionInputObject = ({ + keys: string +} & PointerActionPosition) | PointerAction + +interface PointerActionPosition { + target?: Element | Locator + coords?: PointerCoords + /* @todo properties not supported + node?: Node; + offset?: number; + */ +} + +type PointerAction = /* PointerPressAction | */ PointerMoveAction + +interface PointerMoveAction extends PointerActionPosition {} + +interface PointerCoords { + x?: number + y?: number + // @todo supports only a subset + // clientX?: number; + // clientY?: number; + // offsetX?: number; + // offsetY?: number; + // pageX?: number; + // pageY?: number; + // screenX?: number; + // screenY?: number; +} + export interface LocatorOptions { /** diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 6e1a59cd6..b5a1a8431 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -9,6 +9,8 @@ import type { Locator, LocatorSelectors, MarkOptions, + PointerInput, + PointerInputNormalized, UserEvent, } from 'vitest/browser' import type { StringifyOptions } from 'vitest/internal/browser' @@ -76,24 +78,32 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent wheel(elementOrOptions, options) { return convertToLocator(elementOrOptions).wheel(options) }, - pointer(options) { + pointer(input) { return ensureAwaited(async () => { - const pointerOptions = await Promise.all(options.map(async (option) => { - if ('target' in option && option.target) { - const target = (await serializeElement(option.target)) + const inputArray = (Array.isArray(input) ? (input as Extract) : [input]) + // @todo make this type-safe + const serializedInputArray = await Promise.all(inputArray.map(async (input) => { + if (typeof input === 'object' && 'target' in input && input.target) { + const target = (await serializeElement(input.target)) return { - ...option, + ...input, target, } } - return option + if (typeof input === 'string') { + return { + keys: input, + } + } + + return input })) await triggerCommand( '__vitest_pointer', - [pointerOptions], + [serializedInputArray], ) }) }, @@ -292,6 +302,7 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options? async pointer(options) { // @todo const _ = options + // await userEvent.pointer() }, } diff --git a/test/browser/fixtures/user-event/pointer.test.ts b/test/browser/fixtures/user-event/pointer.test.ts index b4889a86b..c9c7a87ce 100644 --- a/test/browser/fixtures/user-event/pointer.test.ts +++ b/test/browser/fixtures/user-event/pointer.test.ts @@ -23,7 +23,7 @@ test('click triggers hover events', async ({ expect }) => { const target = page.getByRole("button") await userEvent.pointer([ - { target, action: 'click' }, + { target, keys: '[MouseLeft]' }, { target: document.body }, ]) @@ -51,7 +51,7 @@ test('click at coordinates triggers hover events', async ({ expect }) => { buttonElement.addEventListener('click', click) await userEvent.pointer([ - { coordinates: { x: 11, y: 11 }, action: 'click' }, + { coords: { x: 11, y: 11 }, keys: '[MouseLeft]' }, { target: document.body }, ]) @@ -84,8 +84,8 @@ test('moves between coordinates', async ({ expect }) => { b.addEventListener('mouseenter', enterB) await userEvent.pointer([ - { coordinates: { x: 50, y: 50 } }, - { coordinates: { x: 50, y: 250 } }, + { coords: { x: 50, y: 50 } }, + { coords: { x: 50, y: 250 } }, ]) expect(enterA).toHaveBeenCalledOnce() @@ -111,7 +111,7 @@ test('down only fires mousedown event', async ({ expect }) => { const target = page.getByRole('button') await userEvent.pointer([ - { target, action: 'down' }, + { target, keys: '[MouseLeft>]' }, ]) expect(down).toHaveBeenCalledOnce() @@ -120,10 +120,10 @@ test('down only fires mousedown event', async ({ expect }) => { }) test.for([ - { action: 'down' }, - { action: 'up' }, - { action: 'click' }, -] as const)('pointer $action action works with offsets', async ({ action }, { expect }) => { + { 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 = ` ` @@ -135,7 +135,7 @@ test.for([ buttonElement.addEventListener(action === 'click' ? 'click' : `mouse${action}`, spy) await userEvent.pointer([ - { target: buttonElement, offset: { x: 10, y: 10 }, action }, + { target: buttonElement, coords: { x: 10, y: 10 }, keys }, ]) expect(spy).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ @@ -160,7 +160,7 @@ test('multiple clicks trigger double click', async ({ expect }) => { const target = page.getByRole('button') await userEvent.pointer([ - { target, action: 'click', times: 3 }, + { target, keys: '[MouseLeft]'.repeat(3) }, ]) expect(click).toHaveBeenCalledTimes(3) @@ -188,7 +188,7 @@ test('clicks with middle button', async ({ expect }) => { const target = page.getByRole('button') await userEvent.pointer([ - { target, button: 'middle', action: 'click' }, + { target, keys: '[MouseMiddle]' }, ]) expect(down).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ @@ -217,7 +217,7 @@ test('clicks with right button', async ({ expect }) => { const target = page.getByRole('button') await userEvent.pointer([ - { target, button: 'right', action: 'click' }, + { target, keys: '[MouseRight]' }, ]) expect(down).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ @@ -261,8 +261,8 @@ test('drags and drops', async ({ expect }) => { source.addEventListener('dragend', dragEnd) await userEvent.pointer([ - { target: source, action: 'down' }, - { target: dropTarget, action: 'up' }, + { target: source, keys: '[MouseLeft>]' }, + { target: dropTarget, keys: '[/MouseLeft]' }, ]) expect(dragStart).toHaveBeenCalledOnce() @@ -288,8 +288,8 @@ test('temporary modifiers apply to one action', async ({ expect }) => { const target = page.getByRole('button') await userEvent.pointer([ - { target, action: 'click', keys: '{ShiftLeft}' }, - { target, action: 'click' }, + { target, keys: '[ShiftLeft>][MouseLeft][/ShiftLeft]' }, + { target, keys: '[MouseLeft]' }, ]) expect(click).toHaveBeenCalledTimes(2) @@ -321,10 +321,10 @@ test('persistent modifiers survive multiple actions', async ({ expect }) => { d.addEventListener('click', clickD) await userEvent.pointer([ - { target: a, action: 'click', keys: '{ShiftLeft>}{AltLeft>}' }, - { target: b, action: 'click' }, - { target: c, action: 'click', keys: '{/ShiftLeft}{/AltLeft}' }, - { target: d, action: 'click' }, + { 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 })) @@ -346,9 +346,8 @@ test('modifiers work with coordinates', async ({ expect }) => { await userEvent.pointer([ { - coordinates: { x: 11, y: 11 }, - action: 'click', - keys: '{AltLeft}', + coords: { x: 11, y: 11 }, + keys: '[AltLeft>][MouseLeft][/AltLeft]', }, ]) -- 2.51.2