Skip to content

Commit

Permalink
feat(browser): implement several userEvent methods, add fill and …
Browse files Browse the repository at this point in the history
…`dragAndDrop` events (#5882)
  • Loading branch information
sheremet-va committed Jun 18, 2024
1 parent f969fb0 commit 4dbea4a
Show file tree
Hide file tree
Showing 33 changed files with 1,739 additions and 281 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ docs/public/sponsors
.eslintcache
docs/.vitepress/cache/
!test/cli/fixtures/dotted-files/**/.cache
.vitest-reports
test/browser/test/__screenshots__/**/*
.vitest-reports
4 changes: 2 additions & 2 deletions docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -923,15 +923,15 @@ Minimum number of workers to run tests in. `poolOptions.{threads,vmThreads}.minT
### testTimeout

- **Type:** `number`
- **Default:** `5000`
- **Default:** `5_000` in Node.js, `15_000` if `browser.enabled` is `true`
- **CLI:** `--test-timeout=5000`, `--testTimeout=5000`

Default timeout of a test in milliseconds

### hookTimeout

- **Type:** `number`
- **Default:** `10000`
- **Default:** `10_000` in Node.js, `30_000` if `browser.enabled` is `true`
- **CLI:** `--hook-timeout=10000`, `--hookTimeout=10000`

Default timeout of a hook in milliseconds
Expand Down
483 changes: 454 additions & 29 deletions docs/guide/browser.md

Large diffs are not rendered by default.

142 changes: 120 additions & 22 deletions packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,6 @@ export interface FsOptions {
flag?: string | number
}

export interface TypePayload {
type: string
}
export interface PressPayload {
press: string
}
export interface DownPayload {
down: string
}
export interface UpPayload {
up: string
}

export type SendKeysPayload =
| TypePayload
| PressPayload
| DownPayload
| UpPayload

export interface ScreenshotOptions {
element?: Element
/**
Expand All @@ -57,21 +38,138 @@ export interface BrowserCommands {
options?: BufferEncoding | (FsOptions & { mode?: number | string })
) => Promise<void>
removeFile: (path: string) => Promise<void>
sendKeys: (payload: SendKeysPayload) => Promise<void>
}

export interface UserEvent {
setup: () => UserEvent
/**
* Click on an element. Uses provider's API under the hood and supports all its options.
* @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API
* @see {@link https://webdriver.io/docs/api/element/click/} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API
*/
click: (element: Element, options?: UserEventClickOptions) => Promise<void>
/**
* Triggers a double click event on an element. Uses provider's API under the hood.
* @see {@link https://playwright.dev/docs/api/class-locator#locator-dblclick} Playwright API
* @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/convenience/#dblClick} testing-library API
*/
dblClick: (element: Element, options?: UserEventDoubleClickOptions) => Promise<void>
/**
* 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.
* @example
* await userEvent.selectOptions(select, 'Option 1')
* expect(select).toHaveValue('option-1')
*
* await userEvent.selectOptions(select, 'option-1')
* expect(select).toHaveValue('option-1')
*
* await userEvent.selectOptions(select, [
* screen.getByRole('option', { name: 'Option 1' }),
* screen.getByRole('option', { name: 'Option 2' }),
* ])
* expect(select).toHaveValue(['option-1', 'option-2'])
* @see {@link https://playwright.dev/docs/api/class-locator#locator-select-option} Playwright API
* @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/utility/#-selectoptions-deselectoptions} testing-library API
*/
selectOptions: (
element: Element,
values: HTMLElement | HTMLElement[] | string | string[],
options?: UserEventSelectOptions,
) => Promise<void>
/**
* Type text on the keyboard. If any input is focused, it will receive the text,
* otherwise it will be typed on the document. Uses provider's API under the hood.
* **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers.
* @example
* await userEvent.keyboard('foo') // translates to: f, o, o
* await userEvent.keyboard('{{a[[') // translates to: {, a, [
* await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
* @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/keyboard} testing-library API
*/
keyboard: (text: string) => Promise<void>
/**
* Types text into an element. Uses provider's API under the hood.
* **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers.
* @example
* await userEvent.type(input, 'foo') // translates to: f, o, o
* await userEvent.type(input, '{{a[[') // translates to: {, a, [
* await userEvent.type(input, '{Shift}{f}{o}{o}') // translates to: Shift, f, o, o
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
* @see {@link https://webdriver.io/docs/api/browser/action#key-input-source} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API
*/
type: (element: Element, text: string, options?: UserEventTypeOptions) => Promise<void>
/**
* Removes all text from an element. Uses provider's API under the hood.
* @see {@link https://playwright.dev/docs/api/class-locator#locator-clear} Playwright API
* @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API
*/
clear: (element: Element) => Promise<void>
/**
* Sends a `Tab` key event. Uses provider's API under the hood.
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
* @see {@link https://webdriver.io/docs/api/element/keys} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/convenience/#tab} testing-library API
*/
tab: (options?: UserEventTabOptions) => Promise<void>
/**
* Hovers over an element. Uses provider's API under the hood.
* @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API
* @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API
*/
hover: (element: Element, options?: UserEventHoverOptions) => Promise<void>
/**
* Moves cursor position to the body element. Uses provider's API under the hood.
* By default, the cursor position is in the center (in webdriverio) or in some visible place (in playwright)
* of the body element, so if the current element is already there, this will have no effect.
* @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API
* @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API
*/
unhover: (element: Element, options?: UserEventHoverOptions) => Promise<void>
/**
* Fills an input element with text. This will remove any existing text in the input before typing the new text.
* Uses provider's API under the hood.
* This API is faster than using `userEvent.type` or `userEvent.keyboard`, but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`).
* @example
* await userEvent.fill(input, 'foo') // translates to: f, o, o
* await userEvent.fill(input, '{{a[[') // translates to: {, {, a, [, [
* await userEvent.fill(input, '{Shift}') // translates to: {, S, h, i, f, t, }
* @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API
* @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API
*/
fill: (element: Element, text: string, options?: UserEventFillOptions) => Promise<void>
/**
* Drags a source element on top of the target element. This API is not supported by "preview" provider.
* @see {@link https://playwright.dev/docs/api/class-frame#frame-drag-and-drop} Playwright API
* @see {@link https://webdriver.io/docs/api/element/dragAndDrop/} WebdriverIO API
*/
dragAndDrop: (source: Element, target: Element, options?: UserEventDragAndDropOptions) => Promise<void>
}

export interface UserEventFillOptions {}
export interface UserEventHoverOptions {}
export interface UserEventSelectOptions {}
export interface UserEventClickOptions {}
export interface UserEventDoubleClickOptions {}
export interface UserEventDragAndDropOptions {}

export interface UserEventTabOptions {
shift?: boolean
}

export interface UserEventClickOptions {
[key: string]: any
export interface UserEventTypeOptions {
skipClick?: boolean
skipAutoClose?: boolean
}

type Platform =
Expand Down
27 changes: 23 additions & 4 deletions packages/browser/providers/playwright.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type {
BrowserContext,
BrowserContextOptions,
FrameLocator,
Frame,
LaunchOptions,
Locator,
Page,
} from 'playwright'

Expand All @@ -17,7 +17,26 @@ declare module 'vitest/node' {

export interface BrowserCommandContext {
page: Page
tester: FrameLocator
body: Locator
frame: Frame
context: BrowserContext
}
}

type PWHoverOptions = Parameters<Page['hover']>[1]
type PWClickOptions = Parameters<Page['click']>[1]
type PWDoubleClickOptions = Parameters<Page['dblclick']>[1]
type PWFillOptions = Parameters<Page['fill']>[2]
type PWScreenshotOptions = Parameters<Page['screenshot']>[0]
type PWSelectOptions = Parameters<Page['selectOption']>[2]
type PWDragAndDropOptions = Parameters<Page['dragAndDrop']>[2]

declare module '@vitest/browser/context' {
export interface UserEventHoverOptions extends PWHoverOptions {}
export interface UserEventClickOptions extends PWClickOptions {}
export interface UserEventDoubleClickOptions extends PWDoubleClickOptions {}
export interface UserEventFillOptions extends PWFillOptions {}
export interface UserEventSelectOptions extends PWSelectOptions {}
export interface UserEventDragOptions extends UserEventDragAndDropOptions {}

export interface ScreenshotOptions extends PWScreenshotOptions {}
}
102 changes: 97 additions & 5 deletions packages/browser/src/client/context.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import type { Task, WorkerGlobalState } from 'vitest'
import type {
BrowserPage,
UserEvent,
UserEventClickOptions,
} from '../../context'
import type { BrowserPage, UserEvent, UserEventClickOptions, UserEventTabOptions, UserEventTypeOptions } from '../../context'
import type { BrowserRPC } from './client'
import type { BrowserRunnerState } from './utils'

Expand Down Expand Up @@ -62,11 +58,107 @@ function triggerCommand<T>(command: string, ...args: any[]) {
return rpc().triggerCommand<T>(contextId, command, filepath(), args)
}

const provider = runner().provider

export const userEvent: UserEvent = {
// TODO: actually setup userEvent with config options
setup() {
return userEvent
},
click(element: Element, options: UserEventClickOptions = {}) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_click', xpath, options)
},
dblClick(element: Element, options: UserEventClickOptions = {}) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_dblClick', xpath, options)
},
selectOptions(element, value) {
const values = provider === 'webdriverio'
? getWebdriverioSelectOptions(element, value)
: getSimpleSelectOptions(element, value)
return triggerCommand('__vitest_selectOptions', convertElementToXPath(element), values)
},
type(element: Element, text: string, options: UserEventTypeOptions = {}) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_type', xpath, text, options)
},
clear(element: Element) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_clear', xpath)
},
tab(options: UserEventTabOptions = {}) {
return triggerCommand('__vitest_tab', options)
},
keyboard(text: string) {
return triggerCommand('__vitest_keyboard', text)
},
hover(element: Element) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_hover', xpath)
},
unhover(element: Element) {
const xpath = convertElementToXPath(element.ownerDocument.body)
return triggerCommand('__vitest_hover', xpath)
},

// non userEvent events, but still useful
fill(element: Element, text: string, options) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_fill', xpath, text, options)
},
dragAndDrop(source: Element, target: Element, options = {}) {
const sourceXpath = convertElementToXPath(source)
const targetXpath = convertElementToXPath(target)
return triggerCommand('__vitest_dragAndDrop', sourceXpath, targetXpath, options)
},
}

function getWebdriverioSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement) {
const options = [...element.querySelectorAll('option')] as HTMLOptionElement[]

const arrayValues = Array.isArray(value) ? value : [value]

if (!arrayValues.length) {
return []
}

if (arrayValues.length > 1) {
throw new Error('Provider "webdriverio" doesn\'t support selecting multiple values at once')
}

const optionValue = arrayValues[0]

if (typeof optionValue !== 'string') {
const index = options.indexOf(optionValue as HTMLOptionElement)
if (index === -1) {
throw new Error(`The element ${convertElementToXPath(optionValue)} was not found in the "select" options.`)
}

return [{ index }]
}

const valueIndex = options.findIndex(option => option.value === optionValue)
if (valueIndex !== -1) {
return [{ index: valueIndex }]
}

const labelIndex = options.findIndex(option => option.textContent?.trim() === optionValue || option.ariaLabel === optionValue)

if (labelIndex === -1) {
throw new Error(`The option "${optionValue}" was not found in the "select" options.`)
}

return [{ index: labelIndex }]
}

function getSimpleSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement) {
return (Array.isArray(value) ? value : [value]).map((v) => {
if (typeof v !== 'string') {
return { element: convertElementToXPath(v) }
}
return v
})
}

const screenshotIds: Record<string, Record<string, string>> = {}
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function createIframe(container: HTMLDivElement, file: string) {
iframe.style.position = 'relative'
iframe.setAttribute('allowfullscreen', 'true')
iframe.setAttribute('allow', 'clipboard-write;')
iframe.setAttribute('name', 'vitest-iframe')

iframes.set(file, iframe)
container.appendChild(iframe)
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/public/esm-client-injector.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ window.__vitest_browser_runner__ = {
files: { __VITEST_FILES__ },
type: { __VITEST_TYPE__ },
contextId: { __VITEST_CONTEXT_ID__ },
provider: { __VITEST_PROVIDER__ },
};

const config = __vitest_browser_runner__.config;
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/tester.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
{__VITEST_SCRIPTS__}
</head>
<body
data-vitest-body
style="
width: 100%;
height: 100%;
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface BrowserRunnerState {
runningFiles: string[]
moduleCache: WorkerGlobalState['moduleCache']
config: ResolvedConfig
provider: string
viteConfig: {
root: string
}
Expand Down
Loading

0 comments on commit 4dbea4a

Please sign in to comment.