Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/api/browser/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,63 @@ page.getByText('Hello').elements() // ✅ [HTMLElement, HTMLElement]
page.getByText('Hello USA').elements() // ✅ []
```

### findElement <Version>4.1.0</Version> {#findelement}

```ts
function findElement(
options?: SelectorOptions
): Promise<HTMLElement | SVGElement>
```

::: danger WARNING
This is an escape hatch for cases where you need the raw DOM element — for example, to pass it to a third-party library like FormKit that doesn't accept Vitest locators. If you are interacting with the element yourself, use other [builtin methods](#methods) instead.
:::

This method returns an element matching the locator. Unlike [`.element()`](#element), this method will wait and retry until a matching element appears in the DOM, using increasing intervals (0, 20, 50, 100, 100, 500ms).

If _no element_ is found before the timeout, an error is thrown. By default, the timeout matches the test timeout.

If _multiple elements_ match the selector and `strict` is `true` (the default), an error is thrown immediately without retrying. Set `strict` to `false` to return the first matching element instead.

It accepts options:

- `timeout: number` - How long to wait in milliseconds until at least one element is found. By default, this shares timeout with the test.
- `strict: boolean` - When `true` (default), throws an error if multiple elements match the locator. When `false`, returns the first matching element.

Consider the following DOM structure:

```html
<div>Hello <span>World</span></div>
<div>Hello Germany</div>
<div>Hello</div>
```

These locators will resolve successfully:

```ts
await page.getByText('Hello World').findElement() // ✅ HTMLDivElement
await page.getByText('World').findElement() // ✅ HTMLSpanElement
await page.getByText('Hello Germany').findElement() // ✅ HTMLDivElement
```

These locators will throw an error:

```ts
// multiple elements match, strict mode rejects
await page.getByText('Hello').findElement() // ❌
await page.getByText(/^Hello/).findElement() // ❌

// no matching element before timeout
await page.getByText('Hello USA').findElement() // ❌
```

Using `strict: false` to allow multiple matches:

```ts
// returns the first matching element instead of throwing
await page.getByText('Hello').findElement({ strict: false }) // ✅ HTMLDivElement
```

### all

```ts
Expand Down
62 changes: 44 additions & 18 deletions packages/browser-preview/src/locators.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import type {
UserEventClearOptions,
UserEventClickOptions,
UserEventFillOptions,
UserEventHoverOptions,
UserEventSelectOptions,
UserEventUploadOptions,
UserEventWheelOptions,
} from 'vitest/browser'
import {
convertElementToCssSelector,
getByAltTextSelector,
Expand Down Expand Up @@ -26,40 +35,57 @@ class PreviewLocator extends Locator {
return selectors.join(', ')
}

click(): Promise<void> {
return userEvent.click(this.element())
async click(options?: UserEventClickOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.click(element)
}

dblClick(): Promise<void> {
return userEvent.dblClick(this.element())
async dblClick(options?: UserEventClickOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.dblClick(element)
}

tripleClick(): Promise<void> {
return userEvent.tripleClick(this.element())
async tripleClick(options?: UserEventClickOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.tripleClick(element)
}

hover(): Promise<void> {
return userEvent.hover(this.element())
async hover(options?: UserEventHoverOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.hover(element)
}

unhover(): Promise<void> {
return userEvent.unhover(this.element())
async unhover(options?: UserEventHoverOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.unhover(element)
}

async fill(text: string): Promise<void> {
return userEvent.fill(this.element(), text)
async fill(text: string, options?: UserEventFillOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.fill(element, text)
}

async upload(file: string | string[] | File | File[]): Promise<void> {
return userEvent.upload(this.element(), file)
async upload(file: string | string[] | File | File[], options?: UserEventUploadOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.upload(element, file)
}

selectOptions(options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise<void> {
return userEvent.selectOptions(this.element(), options)
async wheel(options: UserEventWheelOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.wheel(element, options)
}

clear(): Promise<void> {
return userEvent.clear(this.element())
async selectOptions(
options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[],
settings?: UserEventSelectOptions,
): Promise<void> {
const element = await this.findElement(settings)
return userEvent.selectOptions(element, options)
}

async clear(options?: UserEventClearOptions): Promise<void> {
const element = await this.findElement(options)
return userEvent.clear(element)
}

protected locator(selector: string) {
Expand Down
14 changes: 14 additions & 0 deletions packages/browser-preview/src/preview.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SelectorOptions } from 'vitest/browser'
import type { BrowserProvider, BrowserProviderOption, TestProject } from 'vitest/node'
import { nextTick } from 'node:process'
import { defineBrowserProvider } from '@vitest/browser'
Expand Down Expand Up @@ -60,3 +61,16 @@ export class PreviewBrowserProvider implements BrowserProvider {

async close(): Promise<void> {}
}

declare module 'vitest/browser' {
export interface UserEventClickOptions extends SelectorOptions {}
export interface UserEventHoverOptions extends SelectorOptions {}
export interface UserEventFillOptions extends SelectorOptions {}
export interface UserEventSelectOptions extends SelectorOptions {}
export interface UserEventClearOptions extends SelectorOptions {}
export interface UserEventDoubleClickOptions extends SelectorOptions {}
export interface UserEventTripleClickOptions extends SelectorOptions {}
export interface UserEventUploadOptions extends SelectorOptions {}
export interface UserEventWheelBaseOptions extends SelectorOptions {}
export interface LocatorScreenshotOptions extends SelectorOptions {}
}
104 changes: 98 additions & 6 deletions packages/browser-webdriverio/src/locators.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type {
LocatorScreenshotOptions,
UserEventClearOptions,
UserEventClickOptions,
UserEventDragAndDropOptions,
UserEventFillOptions,
UserEventHoverOptions,
UserEventSelectOptions,
UserEventWheelOptions,
} from 'vitest/browser'
import {
convertElementToCssSelector,
ensureAwaited,
getByAltTextSelector,
getByLabelSelector,
getByPlaceholderSelector,
Expand All @@ -16,6 +21,7 @@ import {
getIframeScale,
Locator,
selectorEngine,
triggerCommandWithTrace,
} from '@vitest/browser/locators'
import { page, server, utils } from 'vitest/browser'
import { __INTERNAL } from 'vitest/internal/browser'
Expand All @@ -25,6 +31,13 @@ class WebdriverIOLocator extends Locator {
super()
}

// This exists to avoid calling `this.elements` in `this.selector`'s getter in interactive actions
private withElement(element: Element, error: Error | undefined) {
const pwSelector = selectorEngine.generateSelectorSimple(element)
const cssSelector = convertElementToCssSelector(element)
return new ElementWebdriverIOLocator(cssSelector, error, pwSelector, element)
}

override get selector(): string {
const selectors = this.elements().map(element => convertElementToCssSelector(element))
if (!selectors.length) {
Expand All @@ -42,33 +55,85 @@ class WebdriverIOLocator extends Locator {
}

public override click(options?: UserEventClickOptions): Promise<void> {
return super.click(processClickOptions(options))
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).click(processClickOptions(options))
})
}

public override dblClick(options?: UserEventClickOptions): Promise<void> {
return super.dblClick(processClickOptions(options))
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).dblClick(processClickOptions(options))
})
}

public override tripleClick(options?: UserEventClickOptions): Promise<void> {
return super.tripleClick(processClickOptions(options))
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).tripleClick(processClickOptions(options))
})
}

public selectOptions(
value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[],
options?: UserEventSelectOptions,
): Promise<void> {
const values = getWebdriverioSelectOptions(this.element(), value)
return this.triggerCommand('__vitest_selectOptions', this.selector, values, options)
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
const values = getWebdriverioSelectOptions(element, value)
return triggerCommandWithTrace<void>({
name: '__vitest_selectOptions',
arguments: [convertElementToCssSelector(element), values, options],
errorSource: error,
})
})
}

public override hover(options?: UserEventHoverOptions): Promise<void> {
return super.hover(processHoverOptions(options))
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).hover(processHoverOptions(options))
})
}

public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise<void> {
// playwright doesn't enforce a single element, it selects the first one,
// so we just follow the behavior
return super.dropTo(target, processDragAndDropOptions(options))
}

public override wheel(options: UserEventWheelOptions): Promise<void> {
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).wheel(options)
})
}

public override clear(options?: UserEventClearOptions): Promise<void> {
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).clear(options)
})
}

public override fill(text: string, options?: UserEventFillOptions): Promise<void> {
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).fill(text, options)
})
}

public override screenshot(options?: LocatorScreenshotOptions): Promise<any> {
return ensureAwaited(async (error) => {
const element = await this.findElement(options)
return this.withElement(element, error).screenshot(options)
})
}

// playwright doesn't enforce a single element in upload
// public override async upload(): Promise<void>

protected locator(selector: string) {
return new WebdriverIOLocator(`${this._pwSelector} >> ${selector}`, this._container)
}
Expand All @@ -78,6 +143,33 @@ class WebdriverIOLocator extends Locator {
}
}

const kElementLocator = Symbol.for('$$vitest:locator-resolved')

class ElementWebdriverIOLocator extends Locator {
public [kElementLocator] = true

constructor(
private _cssSelector: string,
protected _errorSource: Error | undefined,
protected _pwSelector: string,
protected _container: Element,
) {
super()
}

override get selector() {
return this._cssSelector
}

protected locator(_selector: string): Locator {
throw new Error(`should not be called`)
}

protected elementLocator(_element: Element): Locator {
throw new Error(`should not be called`)
}
}

page.extend({
getByLabelText(text, options) {
return new WebdriverIOLocator(getByLabelSelector(text, options))
Expand Down
13 changes: 10 additions & 3 deletions packages/browser-webdriverio/src/webdriverio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Capabilities } from '@wdio/types'
import type {
ScreenshotComparatorRegistry,
ScreenshotMatcherOptions,
SelectorOptions,
} from 'vitest/browser'
import type {
BrowserCommand,
Expand Down Expand Up @@ -288,15 +289,21 @@ export class WebdriverBrowserProvider implements BrowserProvider {
}

declare module 'vitest/browser' {
export interface UserEventClickOptions extends Partial<ClickOptions> {}
export interface UserEventHoverOptions extends MoveToOptions {}

export interface UserEventClickOptions extends Partial<ClickOptions>, SelectorOptions {}
export interface UserEventHoverOptions extends MoveToOptions, SelectorOptions {}
export interface UserEventDragAndDropOptions extends DragAndDropOptions {
sourceX?: number
sourceY?: number
targetX?: number
targetY?: number
}
export interface UserEventFillOptions extends SelectorOptions {}
export interface UserEventSelectOptions extends SelectorOptions {}
export interface UserEventClearOptions extends SelectorOptions {}
export interface UserEventDoubleClickOptions extends SelectorOptions {}
export interface UserEventTripleClickOptions extends SelectorOptions {}
export interface UserEventWheelBaseOptions extends SelectorOptions {}
export interface LocatorScreenshotOptions extends SelectorOptions {}
}

declare module 'vitest/node' {
Expand Down
Loading
Loading