mirror of
https://github.com/vitest-dev/vitest.git
synced 2025-12-08 18:26:03 +00:00
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import type { Options as TestingLibraryOptions, UserEvent as TestingLibraryUserEvent } from '@testing-library/user-event'
|
|
import type { BrowserRPC } from '@vitest/browser/client'
|
|
import type { RunnerTask } from 'vitest'
|
|
import type {
|
|
BrowserPage,
|
|
Locator,
|
|
UserEvent,
|
|
UserEventClickOptions,
|
|
UserEventDragAndDropOptions,
|
|
UserEventHoverOptions,
|
|
UserEventTabOptions,
|
|
UserEventTypeOptions,
|
|
} from '../../../context'
|
|
import { convertElementToCssSelector, getBrowserState, getWorkerState } from '../utils'
|
|
|
|
// this file should not import anything directly, only types and utils
|
|
|
|
const state = () => getWorkerState()
|
|
// @ts-expect-error not typed global
|
|
const provider = __vitest_browser_runner__.provider
|
|
function filepath() {
|
|
return getWorkerState().filepath || getWorkerState().current?.file?.filepath || undefined
|
|
}
|
|
const rpc = () => getWorkerState().rpc as any as BrowserRPC
|
|
const contextId = getBrowserState().contextId
|
|
const channel = new BroadcastChannel(`vitest:${contextId}`)
|
|
|
|
function triggerCommand<T>(command: string, ...args: any[]) {
|
|
return rpc().triggerCommand<T>(contextId, command, filepath(), args)
|
|
}
|
|
|
|
export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent {
|
|
let __tl_user_event__ = __tl_user_event_base__?.setup(options ?? {})
|
|
const keyboard = {
|
|
unreleased: [] as string[],
|
|
}
|
|
|
|
return {
|
|
setup(options?: any) {
|
|
return createUserEvent(__tl_user_event_base__, options)
|
|
},
|
|
async cleanup() {
|
|
if (typeof __tl_user_event_base__ !== 'undefined') {
|
|
__tl_user_event__ = __tl_user_event_base__?.setup(options ?? {})
|
|
return
|
|
}
|
|
await triggerCommand('__vitest_cleanup', keyboard)
|
|
keyboard.unreleased = []
|
|
},
|
|
click(element: Element | Locator, options: UserEventClickOptions = {}) {
|
|
return convertToLocator(element).click(processClickOptions(options))
|
|
},
|
|
dblClick(element: Element | Locator, options: UserEventClickOptions = {}) {
|
|
return convertToLocator(element).dblClick(processClickOptions(options))
|
|
},
|
|
tripleClick(element: Element | Locator, options: UserEventClickOptions = {}) {
|
|
return convertToLocator(element).tripleClick(processClickOptions(options))
|
|
},
|
|
selectOptions(element, value) {
|
|
return convertToLocator(element).selectOptions(value)
|
|
},
|
|
clear(element: Element | Locator) {
|
|
return convertToLocator(element).clear()
|
|
},
|
|
hover(element: Element | Locator, options: UserEventHoverOptions = {}) {
|
|
return convertToLocator(element).hover(processHoverOptions(options))
|
|
},
|
|
unhover(element: Element | Locator, options: UserEventHoverOptions = {}) {
|
|
return convertToLocator(element).unhover(options)
|
|
},
|
|
upload(element: Element | Locator, files: string | string[] | File | File[]) {
|
|
return convertToLocator(element).upload(files)
|
|
},
|
|
|
|
// non userEvent events, but still useful
|
|
fill(element: Element | Locator, text: string, options) {
|
|
return convertToLocator(element).fill(text, options)
|
|
},
|
|
dragAndDrop(source: Element | Locator, target: Element | Locator, options = {}) {
|
|
const sourceLocator = convertToLocator(source)
|
|
const targetLocator = convertToLocator(target)
|
|
return sourceLocator.dropTo(targetLocator, processDragAndDropOptions(options))
|
|
},
|
|
|
|
// testing-library user-event
|
|
async type(element: Element | Locator, text: string, options: UserEventTypeOptions = {}) {
|
|
if (typeof __tl_user_event__ !== 'undefined') {
|
|
return __tl_user_event__.type(
|
|
element instanceof Element ? element : element.element(),
|
|
text,
|
|
options,
|
|
)
|
|
}
|
|
|
|
const selector = convertToSelector(element)
|
|
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
|
|
'__vitest_type',
|
|
selector,
|
|
text,
|
|
{ ...options, unreleased: keyboard.unreleased },
|
|
)
|
|
keyboard.unreleased = unreleased
|
|
},
|
|
tab(options: UserEventTabOptions = {}) {
|
|
if (typeof __tl_user_event__ !== 'undefined') {
|
|
return __tl_user_event__.tab(options)
|
|
}
|
|
return triggerCommand('__vitest_tab', options)
|
|
},
|
|
async keyboard(text: string) {
|
|
if (typeof __tl_user_event__ !== 'undefined') {
|
|
return __tl_user_event__.keyboard(text)
|
|
}
|
|
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
|
|
'__vitest_keyboard',
|
|
text,
|
|
keyboard,
|
|
)
|
|
keyboard.unreleased = unreleased
|
|
},
|
|
}
|
|
}
|
|
|
|
export function cdp() {
|
|
return getBrowserState().cdp!
|
|
}
|
|
|
|
const screenshotIds: Record<string, Record<string, string>> = {}
|
|
export const page: BrowserPage = {
|
|
viewport(width, height) {
|
|
const id = getBrowserState().iframeId
|
|
channel.postMessage({ type: 'viewport', width, height, id })
|
|
return new Promise((resolve, reject) => {
|
|
channel.addEventListener('message', function handler(e) {
|
|
if (e.data.type === 'viewport:done' && e.data.id === id) {
|
|
channel.removeEventListener('message', handler)
|
|
resolve()
|
|
}
|
|
if (e.data.type === 'viewport:fail' && e.data.id === id) {
|
|
channel.removeEventListener('message', handler)
|
|
reject(new Error(e.data.error))
|
|
}
|
|
})
|
|
})
|
|
},
|
|
async screenshot(options = {}) {
|
|
const currentTest = getWorkerState().current
|
|
if (!currentTest) {
|
|
throw new Error('Cannot take a screenshot outside of a test.')
|
|
}
|
|
|
|
if (currentTest.concurrent) {
|
|
throw new Error(
|
|
'Cannot take a screenshot in a concurrent test because '
|
|
+ 'concurrent tests run at the same time in the same iframe and affect each other\'s environment. '
|
|
+ 'Use a non-concurrent test to take a screenshot.',
|
|
)
|
|
}
|
|
|
|
const repeatCount = currentTest.result?.repeatCount ?? 0
|
|
const taskName = getTaskFullName(currentTest)
|
|
const number = screenshotIds[repeatCount]?.[taskName] ?? 1
|
|
|
|
screenshotIds[repeatCount] ??= {}
|
|
screenshotIds[repeatCount][taskName] = number + 1
|
|
|
|
const name
|
|
= options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png`
|
|
|
|
return triggerCommand('__vitest_screenshot', name, {
|
|
...options,
|
|
element: options.element
|
|
? convertToSelector(options.element)
|
|
: undefined,
|
|
})
|
|
},
|
|
getByRole() {
|
|
throw new Error('Method "getByRole" is not implemented in the current provider.')
|
|
},
|
|
getByLabelText() {
|
|
throw new Error('Method "getByLabelText" is not implemented in the current provider.')
|
|
},
|
|
getByTestId() {
|
|
throw new Error('Method "getByTestId" is not implemented in the current provider.')
|
|
},
|
|
getByAltText() {
|
|
throw new Error('Method "getByAltText" is not implemented in the current provider.')
|
|
},
|
|
getByPlaceholder() {
|
|
throw new Error('Method "getByPlaceholder" is not implemented in the current provider.')
|
|
},
|
|
getByText() {
|
|
throw new Error('Method "getByText" is not implemented in the current provider.')
|
|
},
|
|
getByTitle() {
|
|
throw new Error('Method "getByTitle" is not implemented in the current provider.')
|
|
},
|
|
elementLocator() {
|
|
throw new Error('Method "elementLocator" is not implemented in the current provider.')
|
|
},
|
|
extend(methods) {
|
|
for (const key in methods) {
|
|
(page as any)[key] = (methods as any)[key]
|
|
}
|
|
return page
|
|
},
|
|
}
|
|
|
|
function convertToLocator(element: Element | Locator): Locator {
|
|
if (element instanceof Element) {
|
|
return page.elementLocator(element)
|
|
}
|
|
return element
|
|
}
|
|
|
|
function convertToSelector(elementOrLocator: Element | Locator): string {
|
|
if (!elementOrLocator) {
|
|
throw new Error('Expected element or locator to be defined.')
|
|
}
|
|
if (elementOrLocator instanceof Element) {
|
|
return convertElementToCssSelector(elementOrLocator)
|
|
}
|
|
if ('selector' in elementOrLocator) {
|
|
return (elementOrLocator as any).selector
|
|
}
|
|
throw new Error('Expected element or locator to be an instance of Element or Locator.')
|
|
}
|
|
|
|
function getTaskFullName(task: RunnerTask): string {
|
|
return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name
|
|
}
|
|
|
|
function processClickOptions(options_?: UserEventClickOptions) {
|
|
// only ui scales the iframe, so we need to adjust the position
|
|
if (!options_ || !state().config.browser.ui) {
|
|
return options_
|
|
}
|
|
if (provider === 'playwright') {
|
|
const options = options_ as NonNullable<
|
|
Parameters<import('playwright').Page['click']>[1]
|
|
>
|
|
if (options.position) {
|
|
options.position = processPlaywrightPosition(options.position)
|
|
}
|
|
}
|
|
if (provider === 'webdriverio') {
|
|
const options = options_ as import('webdriverio').ClickOptions
|
|
if (options.x != null || options.y != null) {
|
|
const cache = {}
|
|
if (options.x != null) {
|
|
options.x = scaleCoordinate(options.x, cache)
|
|
}
|
|
if (options.y != null) {
|
|
options.y = scaleCoordinate(options.y, cache)
|
|
}
|
|
}
|
|
}
|
|
return options_
|
|
}
|
|
|
|
function processHoverOptions(options_?: UserEventHoverOptions) {
|
|
// only ui scales the iframe, so we need to adjust the position
|
|
if (!options_ || !state().config.browser.ui) {
|
|
return options_
|
|
}
|
|
|
|
if (provider === 'playwright') {
|
|
const options = options_ as NonNullable<
|
|
Parameters<import('playwright').Page['hover']>[1]
|
|
>
|
|
if (options.position) {
|
|
options.position = processPlaywrightPosition(options.position)
|
|
}
|
|
}
|
|
if (provider === 'webdriverio') {
|
|
const options = options_ as import('webdriverio').MoveToOptions
|
|
const cache = {}
|
|
if (options.xOffset != null) {
|
|
options.xOffset = scaleCoordinate(options.xOffset, cache)
|
|
}
|
|
if (options.yOffset != null) {
|
|
options.yOffset = scaleCoordinate(options.yOffset, cache)
|
|
}
|
|
}
|
|
return options_
|
|
}
|
|
|
|
function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) {
|
|
// only ui scales the iframe, so we need to adjust the position
|
|
if (!options_ || !state().config.browser.ui) {
|
|
return options_
|
|
}
|
|
if (provider === 'playwright') {
|
|
const options = options_ as NonNullable<
|
|
Parameters<import('playwright').Page['dragAndDrop']>[2]
|
|
>
|
|
if (options.sourcePosition) {
|
|
options.sourcePosition = processPlaywrightPosition(options.sourcePosition)
|
|
}
|
|
if (options.targetPosition) {
|
|
options.targetPosition = processPlaywrightPosition(options.targetPosition)
|
|
}
|
|
}
|
|
if (provider === 'webdriverio') {
|
|
const cache = {}
|
|
const options = options_ as import('webdriverio').DragAndDropOptions & {
|
|
targetX?: number
|
|
targetY?: number
|
|
sourceX?: number
|
|
sourceY?: number
|
|
}
|
|
if (options.sourceX != null) {
|
|
options.sourceX = scaleCoordinate(options.sourceX, cache)
|
|
}
|
|
if (options.sourceY != null) {
|
|
options.sourceY = scaleCoordinate(options.sourceY, cache)
|
|
}
|
|
if (options.targetX != null) {
|
|
options.targetX = scaleCoordinate(options.targetX, cache)
|
|
}
|
|
if (options.targetY != null) {
|
|
options.targetY = scaleCoordinate(options.targetY, cache)
|
|
}
|
|
}
|
|
return options_
|
|
}
|
|
|
|
function scaleCoordinate(coordinate: number, cache: any) {
|
|
return Math.round(coordinate * getCachedScale(cache))
|
|
}
|
|
|
|
function getCachedScale(cache: { scale: number | undefined }) {
|
|
return cache.scale ??= getIframeScale()
|
|
}
|
|
|
|
function processPlaywrightPosition(position: { x: number; y: number }) {
|
|
const scale = getIframeScale()
|
|
if (position.x != null) {
|
|
position.x *= scale
|
|
}
|
|
if (position.y != null) {
|
|
position.y *= scale
|
|
}
|
|
return position
|
|
}
|
|
|
|
function getIframeScale() {
|
|
const testerUi = window.parent.document.querySelector('#tester-ui') as HTMLElement | null
|
|
if (!testerUi) {
|
|
throw new Error(`Cannot find Tester element. This is a bug in Vitest. Please, open a new issue with reproduction.`)
|
|
}
|
|
const scaleAttribute = testerUi.getAttribute('data-scale')
|
|
const scale = Number(scaleAttribute)
|
|
if (Number.isNaN(scale)) {
|
|
throw new TypeError(`Cannot parse scale value from Tester element (${scaleAttribute}). This is a bug in Vitest. Please, open a new issue with reproduction.`)
|
|
}
|
|
return scale
|
|
}
|