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..d5465674c --- /dev/null +++ b/packages/browser-playwright/src/commands/pointer.ts @@ -0,0 +1,73 @@ +import type { SerializedUserEventPointerOptions } from 'vitest/browser' +import type { UserEventCommand } from './utils' +import { parseKeyDef } from '@vitest/browser' +import { hover } from './hover' + +type PointerCommand = (options: readonly SerializedUserEventPointerOptions[]) => void + +export const pointer: UserEventCommand = async ( + context, + options, +) => { + // @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) + } + } + } + + if (option.target) { + await hover(context, option.target) + } + else { + await context.page.mouse.move(option.coordinates.x, option.coordinates.y) + } + + if (option.action) { + const options = { + button: option.button, + } + + switch (option.action) { + case 'down': { + await context.page.mouse.down(options) + break + } + case 'up': { + await context.page.mouse.up(options) + break + } + case 'click': { + await context.page.mouse.down(options) + await context.page.mouse.up(options) + break + } + } + } + + for (const key of keysToRelease) { + await context.page.keyboard.up(key) + } + } +} diff --git a/packages/browser-playwright/src/commands/utils.ts b/packages/browser-playwright/src/commands/utils.ts index 0407ca707..73dbc1f0f 100644 --- a/packages/browser-playwright/src/commands/utils.ts +++ b/packages/browser-playwright/src/commands/utils.ts @@ -7,6 +7,7 @@ export type UserEventCommand any> = BrowserCommand< > type ConvertElementToLocator = T extends Element | Locator ? SerializedLocator : T +// @todo: check if this could take care of nested locators (e.g., `{ target: Locator }[]`) type ConvertUserEventParameters = { [K in keyof T]: ConvertElementToLocator; } diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 6f2d01461..6a4ad347b 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -251,6 +251,10 @@ export interface UserEvent { * await userEvent.wheel(container, { direction: 'up', times: 5 }) */ wheel(element: Element | Locator, options: UserEventWheelOptions): Promise + /** + * @todo + */ + pointer(options: readonly UserEventPointerOptions[]): 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. @@ -441,6 +445,84 @@ export interface UserEventWheelDirectionOptions extends UserEventWheelBaseOption */ export type UserEventWheelOptions = UserEventWheelDeltaOptions | UserEventWheelDirectionOptions +/** + * Base options shared by all pointer event configurations. + * + * @since 5.0.0 + */ +export interface UserEventPointerBaseOptions { + /** + * 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' | 'click' + +} + +/** + * Options for triggering pointer events using coordinates. + * + * @since 5.0.0 + */ +export interface UserEventPointerCoordinatesOptions extends UserEventPointerBaseOptions { + /** + * The coordinates to interact with. + */ + coordinates: { x: number; y: number } + target?: undefined +} + +/** + * Options for triggering pointer events using a target element. + * + * @since 5.0.0 + */ +export interface UserEventPointerTargetOptions extends UserEventPointerBaseOptions { + /** + * The target element to interact with. + */ + target: Element | Locator + coordinates?: undefined +} + +/** + * 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 = UserEventPointerCoordinatesOptions | UserEventPointerTargetOptions + +/** + * Serialized options for triggering pointer events using a target element. + * + * @internal + */ +export interface SerializedUserEventPointerTargetOptions extends UserEventPointerTargetOptions { + target: SerializedLocator +} + +/** + * Serialized options for triggering pointer events. + * + * @internal + */ +export type SerializedUserEventPointerOptions = UserEventPointerCoordinatesOptions | SerializedUserEventPointerTargetOptions + export interface LocatorOptions { /** * Whether to find an exact match: case-sensitive and whole-string. Default to false. Ignored when locating by a diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 65e586e45..732666cf2 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -10,7 +10,6 @@ import type { LocatorSelectors, MarkOptions, UserEvent, - UserEventWheelOptions, } from 'vitest/browser' import type { StringifyOptions } from 'vitest/internal/browser' import type { IframeViewportEvent } from '../client' @@ -20,7 +19,7 @@ import type { BrowserTraceEntryStatus } from './trace' import { vi } from 'vitest' import { __INTERNAL, stringify } from 'vitest/internal/browser' import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' -import { isLocator, processTimeoutOptions, resolveUserEventWheelOptions, serializeElement } from './tester-utils' +import { isLocator, processTimeoutOptions, resolveUserEventPointerOptions, resolveUserEventWheelOptions, serializeElement } from './tester-utils' import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace' // this file should not import anything directly, only types and utils @@ -74,9 +73,17 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent tripleClick(element, options) { return convertToLocator(element).tripleClick(options) }, - wheel(elementOrOptions: Element | Locator, options: UserEventWheelOptions) { + wheel(elementOrOptions, options) { return convertToLocator(elementOrOptions).wheel(options) }, + pointer(options) { + return ensureAwaited(async () => { + await triggerCommand( + '__vitest_pointer', + [await resolveUserEventPointerOptions(options)], + ) + }) + }, selectOptions(element, value, options) { return convertToLocator(element).selectOptions(value, options) }, @@ -244,7 +251,7 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options? 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) @@ -269,6 +276,10 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options? resolvedElement.dispatchEvent(wheelEvent) } }, + async pointer(options) { + // @todo + const _ = options + }, } 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 b53794ae5..a984b4568 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -1,4 +1,4 @@ -import type { Locator, SelectorOptions, SerializedLocator, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' +import type { Locator, SelectorOptions, SerializedLocator, SerializedUserEventPointerOptions, UserEventPointerOptions, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' import type { BrowserRPC } from '../client' import type { BrowserTraceEntryStatus } from './trace' import { __INTERNAL } from 'vitest/internal/browser' @@ -353,3 +353,18 @@ export function resolveUserEventWheelOptions(options: UserEventWheelOptions): Us times: options.times, } } + +export async function resolveUserEventPointerOptions(options: readonly UserEventPointerOptions[]): Promise { + return await Promise.all(options.map(async (option) => { + if ('target' in option && option.target) { + const target = (await serializeElement(option.target)) + + return { + ...option, + target, + } + } + + return option + })) +} 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..b93836508 --- /dev/null +++ b/test/browser/fixtures/user-event/pointer.test.ts @@ -0,0 +1,36 @@ +import { test, vi } from 'vitest' +import { userEvent, page } from 'vitest/browser' + +type PointerAction = (event: PointerEvent) => void + +test('hovers and clicks a button', async ({ expect }) => { + document.body.innerHTML = ` +
+ +
+ `; + + 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, action: 'click' }, + { target: document.body }, + ]) + + expect(enter).toHaveBeenCalledOnce() + expect(click).toHaveBeenCalledOnce() + expect(leave).toHaveBeenCalledOnce() + + expect(enter).toHaveBeenCalledBefore(click) + expect(click).toHaveBeenCalledBefore(leave) +})