diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md
index f81e040f8..aea66be97 100644
--- a/docs/api/browser/locators.md
+++ b/docs/api/browser/locators.md
@@ -935,6 +935,63 @@ page.getByText('Hello').elements() // ✅ [HTMLElement, HTMLElement]
page.getByText('Hello USA').elements() // ✅ []
```
+### findElement 4.1.0 {#findelement}
+
+```ts
+function findElement(
+ options?: SelectorOptions
+): Promise
+```
+
+::: 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
+
Hello World
+
Hello Germany
+
Hello
+```
+
+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
diff --git a/packages/browser-preview/src/locators.ts b/packages/browser-preview/src/locators.ts
index 8217002bb..ea1f3859c 100644
--- a/packages/browser-preview/src/locators.ts
+++ b/packages/browser-preview/src/locators.ts
@@ -1,3 +1,12 @@
+import type {
+ UserEventClearOptions,
+ UserEventClickOptions,
+ UserEventFillOptions,
+ UserEventHoverOptions,
+ UserEventSelectOptions,
+ UserEventUploadOptions,
+ UserEventWheelOptions,
+} from 'vitest/browser'
import {
convertElementToCssSelector,
getByAltTextSelector,
@@ -26,40 +35,57 @@ class PreviewLocator extends Locator {
return selectors.join(', ')
}
- click(): Promise {
- return userEvent.click(this.element())
+ async click(options?: UserEventClickOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.click(element)
}
- dblClick(): Promise {
- return userEvent.dblClick(this.element())
+ async dblClick(options?: UserEventClickOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.dblClick(element)
}
- tripleClick(): Promise {
- return userEvent.tripleClick(this.element())
+ async tripleClick(options?: UserEventClickOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.tripleClick(element)
}
- hover(): Promise {
- return userEvent.hover(this.element())
+ async hover(options?: UserEventHoverOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.hover(element)
}
- unhover(): Promise {
- return userEvent.unhover(this.element())
+ async unhover(options?: UserEventHoverOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.unhover(element)
}
- async fill(text: string): Promise {
- return userEvent.fill(this.element(), text)
+ async fill(text: string, options?: UserEventFillOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.fill(element, text)
}
- async upload(file: string | string[] | File | File[]): Promise {
- return userEvent.upload(this.element(), file)
+ async upload(file: string | string[] | File | File[], options?: UserEventUploadOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.upload(element, file)
}
- selectOptions(options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise {
- return userEvent.selectOptions(this.element(), options)
+ async wheel(options: UserEventWheelOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.wheel(element, options)
}
- clear(): Promise {
- return userEvent.clear(this.element())
+ async selectOptions(
+ options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[],
+ settings?: UserEventSelectOptions,
+ ): Promise {
+ const element = await this.findElement(settings)
+ return userEvent.selectOptions(element, options)
+ }
+
+ async clear(options?: UserEventClearOptions): Promise {
+ const element = await this.findElement(options)
+ return userEvent.clear(element)
}
protected locator(selector: string) {
diff --git a/packages/browser-preview/src/preview.ts b/packages/browser-preview/src/preview.ts
index 07bd989d6..8387140df 100644
--- a/packages/browser-preview/src/preview.ts
+++ b/packages/browser-preview/src/preview.ts
@@ -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'
@@ -60,3 +61,16 @@ export class PreviewBrowserProvider implements BrowserProvider {
async close(): Promise {}
}
+
+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 {}
+}
diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts
index 681304b90..e7210d1b9 100644
--- a/packages/browser-webdriverio/src/locators.ts
+++ b/packages/browser-webdriverio/src/locators.ts
@@ -1,11 +1,16 @@
import type {
+ LocatorScreenshotOptions,
+ UserEventClearOptions,
UserEventClickOptions,
UserEventDragAndDropOptions,
+ UserEventFillOptions,
UserEventHoverOptions,
UserEventSelectOptions,
+ UserEventWheelOptions,
} from 'vitest/browser'
import {
convertElementToCssSelector,
+ ensureAwaited,
getByAltTextSelector,
getByLabelSelector,
getByPlaceholderSelector,
@@ -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'
@@ -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) {
@@ -42,33 +55,85 @@ class WebdriverIOLocator extends Locator {
}
public override click(options?: UserEventClickOptions): Promise {
- 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 {
- 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 {
- 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 {
- 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({
+ name: '__vitest_selectOptions',
+ arguments: [convertElementToCssSelector(element), values, options],
+ errorSource: error,
+ })
+ })
}
public override hover(options?: UserEventHoverOptions): Promise {
- 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 {
+ // 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 {
+ return ensureAwaited(async (error) => {
+ const element = await this.findElement(options)
+ return this.withElement(element, error).wheel(options)
+ })
+ }
+
+ public override clear(options?: UserEventClearOptions): Promise {
+ 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 {
+ return ensureAwaited(async (error) => {
+ const element = await this.findElement(options)
+ return this.withElement(element, error).fill(text, options)
+ })
+ }
+
+ public override screenshot(options?: LocatorScreenshotOptions): Promise {
+ 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
+
protected locator(selector: string) {
return new WebdriverIOLocator(`${this._pwSelector} >> ${selector}`, this._container)
}
@@ -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))
diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts
index 7f86aa919..467f3ee31 100644
--- a/packages/browser-webdriverio/src/webdriverio.ts
+++ b/packages/browser-webdriverio/src/webdriverio.ts
@@ -3,6 +3,7 @@ import type { Capabilities } from '@wdio/types'
import type {
ScreenshotComparatorRegistry,
ScreenshotMatcherOptions,
+ SelectorOptions,
} from 'vitest/browser'
import type {
BrowserCommand,
@@ -288,15 +289,21 @@ export class WebdriverBrowserProvider implements BrowserProvider {
}
declare module 'vitest/browser' {
- export interface UserEventClickOptions extends Partial {}
- export interface UserEventHoverOptions extends MoveToOptions {}
-
+ export interface UserEventClickOptions extends Partial, 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' {
diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts
index c09dd565d..094f61e30 100644
--- a/packages/browser/context.d.ts
+++ b/packages/browser/context.d.ts
@@ -21,7 +21,10 @@ export interface CDPSession {
// methods are defined by the provider type augmentation
}
-export interface ScreenshotOptions {
+export interface ScreenshotOptions extends SelectorOptions {
+ /**
+ * The HTML element to screeshot.
+ */
element?: Element | Locator
/**
* Path relative to the current test file.
@@ -157,7 +160,7 @@ export interface ScreenshotMatcherOptions<
comparatorOptions?: ScreenshotComparatorRegistry[ComparatorName]
screenshotOptions?: Omit<
ScreenshotOptions,
- 'element' | 'base64' | 'path' | 'save' | 'type'
+ 'element' | 'base64' | 'path' | 'save' | 'type' | 'strict' | 'timeout'
>
/**
* Time to wait until a stable screenshot is found.
@@ -168,6 +171,13 @@ export interface ScreenshotMatcherOptions<
* @default 5000
*/
timeout?: number
+ /**
+ * Allow only a single element with the same locator.
+ *
+ * If Vitest finds multiple elements, it will throw an error immediately without retrying.
+ * @default true
+ */
+ strict?: boolean
}
export interface UserEvent {
@@ -522,6 +532,22 @@ export interface LocatorSelectors {
export interface FrameLocator extends LocatorSelectors {}
+export interface SelectorOptions {
+ /**
+ * How long to wait until a single element is found. By default, this has the same timeout as the test.
+ *
+ * Vitest will try to find the element in ever increasing intervals: 0, 20, 50, 100, 100, 500.
+ */
+ timeout?: number
+ /**
+ * Allow only a single element with the same locator.
+ *
+ * If Vitest finds multiple elements, it will throw an error immediately without retrying.
+ * @default true
+ */
+ strict?: boolean
+}
+
export interface Locator extends LocatorSelectors {
/**
* Selector string that will be used to locate the element by the browser provider.
@@ -689,13 +715,27 @@ export interface Locator extends LocatorSelectors {
* @see {@link https://vitest.dev/api/browser/locators#filter}
*/
filter(options: LocatorOptions): Locator
+ /**
+ * This method returns an element matching the locator.
+ * Unlike [`.element()`](https://vitest.dev/api/browser/locators#element),
+ * this method will wait and retry until a matching element appears in the DOM,
+ * using increasing intervals (0, 20, 50, 100, 100, 500ms).
+ *
+ * **WARNING:**
+ *
+ * This is an escape hatch for library authors and 3d-party APIs that do not support locators directly.
+ * If you are interacting with the element, use builtin methods instead.
+ * @since 4.1.0
+ * @see {@link https://vitest.dev/api/browser/locators#findelement}
+ */
+ findElement(options?: SelectorOptions): Promise
}
export interface UserEventTabOptions {
shift?: boolean
}
-export interface UserEventTypeOptions {
+export interface UserEventTypeOptions extends SelectorOptions {
skipClick?: boolean
skipAutoClose?: boolean
}
diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts
index 349dfc890..17dfbe30c 100644
--- a/packages/browser/src/client/tester/context.ts
+++ b/packages/browser/src/client/tester/context.ts
@@ -102,7 +102,7 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
// testing-library user-event
async type(element, text, options) {
return ensureAwaited(async (error) => {
- const selector = convertToSelector(element)
+ const selector = await convertToSelector(element, options)
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
'__vitest_type',
[
@@ -323,11 +323,15 @@ export const page: BrowserPage = {
const name
= options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png`
+ const [element, ...mask] = await Promise.all([
+ options.element ? convertToSelector(options.element, options) : undefined,
+ ...('mask' in options
+ ? (options.mask as Array).map(el => convertToSelector(el, options))
+ : []),
+ ])
+
const normalizedOptions = 'mask' in options
- ? {
- ...options,
- mask: (options.mask as Array).map(convertToSelector),
- }
+ ? { ...options, mask }
: options
return ensureAwaited(error => triggerCommand(
@@ -336,9 +340,7 @@ export const page: BrowserPage = {
name,
processTimeoutOptions({
...normalizedOptions,
- element: options.element
- ? convertToSelector(options.element)
- : undefined,
+ element,
} as any /** TODO */),
],
error,
diff --git a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts
index 64c23efbd..46980d7e4 100644
--- a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts
+++ b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts
@@ -40,14 +40,21 @@ export default async function toMatchScreenshot(
? nameOrOptions
: `${this.currentTestName} ${counter.current}`
+ const [element, ...mask] = await Promise.all([
+ convertToSelector(actual, options),
+ ...options.screenshotOptions && 'mask' in options.screenshotOptions
+ ? (options.screenshotOptions.mask as Array)
+ .map(m => convertToSelector(m, options))
+ : [],
+ ])
+
const normalizedOptions: Omit = (
options.screenshotOptions && 'mask' in options.screenshotOptions
? {
...options,
screenshotOptions: {
...options.screenshotOptions,
- mask: (options.screenshotOptions.mask as Array)
- .map(convertToSelector),
+ mask,
},
}
// TS believes `mask` to still be defined as `ReadonlyArray`
@@ -60,7 +67,7 @@ export default async function toMatchScreenshot(
name,
this.currentTestName,
{
- element: convertToSelector(actual),
+ element,
...normalizedOptions,
},
] satisfies ScreenshotMatcherArguments,
diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts
index ec9a2f9eb..1fd0cd9ad 100644
--- a/packages/browser/src/client/tester/locators/index.ts
+++ b/packages/browser/src/client/tester/locators/index.ts
@@ -3,6 +3,7 @@ import type {
LocatorByRoleOptions,
LocatorOptions,
LocatorScreenshotOptions,
+ SelectorOptions,
UserEventClearOptions,
UserEventClickOptions,
UserEventDragAndDropOptions,
@@ -24,10 +25,11 @@ import {
Ivya,
} from 'ivya'
import { page, server, utils } from 'vitest/browser'
-import { __INTERNAL } from 'vitest/internal/browser'
+import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState } from '../../utils'
-import { escapeForTextSelector, isLocator, resolveUserEventWheelOptions } from '../tester-utils'
+import { escapeForTextSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from '../tester-utils'
+export { ensureAwaited } from '../../utils'
export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils'
export {
getByAltTextSelector,
@@ -41,6 +43,14 @@ export {
__INTERNAL._asLocator = asLocator
+const now = Date.now
+const waitForIntervals = [0, 20, 50, 100, 100, 500]
+
+function sleep(ms: number): Promise {
+ const { setTimeout } = getSafeTimers()
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
// we prefer using playwright locators because they are more powerful and support Shadow DOM
export const selectorEngine: Ivya = Ivya.create({
browser: ((name: string) => {
@@ -65,6 +75,7 @@ export abstract class Locator {
private _parsedSelector: ParsedSelector | undefined
protected _container?: Element | undefined
protected _pwSelector?: string | undefined
+ protected _errorSource?: Error
constructor() {
Object.defineProperty(this, kLocator, {
@@ -88,8 +99,12 @@ export abstract class Locator {
}
public wheel(options: UserEventWheelOptions): Promise {
- return ensureAwaited(async () => {
- await this.triggerCommand('__vitest_wheel', this.selector, resolveUserEventWheelOptions(options))
+ return ensureAwaited(async (error) => {
+ await getBrowserState().commands.triggerCommand(
+ '__vitest_wheel',
+ [this.selector, resolveUserEventWheelOptions(options)],
+ error,
+ )
const browser = getBrowserState().config.browser.name
@@ -121,25 +136,31 @@ export abstract class Locator {
}
public async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise {
- const filesPromise = (Array.isArray(files) ? files : [files]).map(async (file) => {
- if (typeof file === 'string') {
- return file
- }
- const bas64String = await new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = () => resolve(reader.result as string)
- reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`))
- reader.readAsDataURL(file)
- })
+ return ensureAwaited(async (error) => {
+ const filesPromise = (Array.isArray(files) ? files : [files]).map(async (file) => {
+ if (typeof file === 'string') {
+ return file
+ }
+ const bas64String = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`))
+ reader.readAsDataURL(file)
+ })
- return {
- name: file.name,
- mimeType: file.type,
- // strip prefix `data:[][;base64],`
- base64: bas64String.slice(bas64String.indexOf(',') + 1),
- }
+ return {
+ name: file.name,
+ mimeType: file.type,
+ // strip prefix `data:[][;base64],`
+ base64: bas64String.slice(bas64String.indexOf(',') + 1),
+ }
+ })
+ return getBrowserState().commands.triggerCommand(
+ '__vitest_upload',
+ [this.selector, await Promise.all(filesPromise), options],
+ error,
+ )
})
- return this.triggerCommand('__vitest_upload', this.selector, await Promise.all(filesPromise), options)
}
public dropTo(target: Locator, options: UserEventDragAndDropOptions = {}): Promise {
@@ -293,12 +314,82 @@ export abstract class Locator {
return this.selector
}
+ public async findElement(options_: SelectorOptions = {}): Promise {
+ const options = processTimeoutOptions(options_)
+ const timeout = options?.timeout
+ const strict = options?.strict ?? true
+ const startTime = now()
+ let intervalIndex = 0
+ while (true) {
+ const elements = this.elements()
+ if (elements.length === 1) {
+ return elements[0]
+ }
+ if (elements.length > 1) {
+ if (strict) {
+ throw createStrictModeViolationError(this._pwSelector || this.selector, elements)
+ }
+ return elements[0]
+ }
+ const elapsed = now() - startTime
+ const isLastCall = timeout != null && elapsed >= timeout
+ if (isLastCall) {
+ throw utils.getElementError(this._pwSelector || this.selector, this._container || document.body)
+ }
+ const interval = waitForIntervals[Math.min(intervalIndex++, waitForIntervals.length - 1)]
+ const nextInterval = timeout != null
+ ? Math.min(interval, timeout - elapsed)
+ : interval
+ await sleep(nextInterval)
+ }
+ }
+
protected triggerCommand(command: string, ...args: any[]): Promise {
- const commands = getBrowserState().commands
- return ensureAwaited(error => commands.triggerCommand(
- command,
- args,
- error,
- ))
+ if (this._errorSource) {
+ return triggerCommandWithTrace({
+ name: command,
+ arguments: args,
+ errorSource: this._errorSource,
+ })
+ }
+ return ensureAwaited(error => triggerCommandWithTrace({
+ name: command,
+ arguments: args,
+ errorSource: error,
+ }))
}
}
+
+export function triggerCommandWithTrace(
+ options: {
+ name: string
+ arguments: unknown[]
+ errorSource?: Error | undefined
+ },
+): Promise {
+ return getBrowserState().commands.triggerCommand(
+ options.name,
+ options.arguments,
+ options.errorSource,
+ )
+}
+
+function createStrictModeViolationError(
+ selector: string,
+ matches: Element[],
+) {
+ const infos = matches.slice(0, 10).map(m => ({
+ preview: selectorEngine.previewNode(m),
+ selector: selectorEngine.generateSelectorSimple(m),
+ }))
+ const lines = infos.map(
+ (info, i) =>
+ `\n ${i + 1}) ${info.preview} aka ${asLocator('javascript', info.selector)}`,
+ )
+ if (infos.length < matches.length) {
+ lines.push('\n ...')
+ }
+ return new Error(
+ `strict mode violation: ${asLocator('javascript', selector)} resolved to ${matches.length} elements:${lines.join('')}\n`,
+ )
+}
diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts
index 2c2e9df2d..1176a2c04 100644
--- a/packages/browser/src/client/tester/tester-utils.ts
+++ b/packages/browser/src/client/tester/tester-utils.ts
@@ -1,9 +1,7 @@
-import type { Locator, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser'
+import type { Locator, SelectorOptions, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser'
import type { BrowserRPC } from '../client'
import { getBrowserState, getWorkerState } from '../utils'
-const provider = getBrowserState().provider
-
/* @__NO_SIDE_EFFECTS__ */
export function convertElementToCssSelector(element: Element): string {
if (!element || !(element instanceof Element)) {
@@ -130,7 +128,6 @@ export class CommandsManager {
() =>
rpc.triggerCommand(sessionId, command, filepath, args).catch((err) => {
// rethrow an error to keep the stack trace in browser
- // const clientError = new Error(err.message)
clientError.message = err.message
clientError.name = err.name
clientError.stack = clientError.stack?.replace(clientError.message, err.message)
@@ -142,12 +139,10 @@ export class CommandsManager {
const now = Date.now
-export function processTimeoutOptions(options_?: T): T | undefined {
+export function processTimeoutOptions(options_: T | undefined): T | undefined {
if (
// if timeout is set, keep it
(options_ && options_.timeout != null)
- // timeout can only be set for playwright commands
- || provider !== 'playwright'
) {
return options_
}
@@ -209,7 +204,10 @@ export function escapeForTextSelector(text: string | RegExp, exact: boolean): st
return `${JSON.stringify(text)}${exact ? 's' : 'i'}`
}
-export function convertToSelector(elementOrLocator: Element | Locator): string {
+const provider = getBrowserState().provider
+const kElementLocator = Symbol.for('$$vitest:locator-resolved')
+
+export async function convertToSelector(elementOrLocator: Element | Locator, options?: SelectorOptions): Promise {
if (!elementOrLocator) {
throw new Error('Expected element or locator to be defined.')
}
@@ -217,7 +215,11 @@ export function convertToSelector(elementOrLocator: Element | Locator): string {
return convertElementToCssSelector(elementOrLocator)
}
if (isLocator(elementOrLocator)) {
- return elementOrLocator.selector
+ if (provider === 'playwright' || kElementLocator in elementOrLocator) {
+ return elementOrLocator.selector
+ }
+ const element = await elementOrLocator.findElement(options)
+ return convertElementToCssSelector(element)
}
throw new Error('Expected element or locator to be an instance of Element or Locator.')
}
diff --git a/packages/browser/src/node/commands/screenshotMatcher/utils.ts b/packages/browser/src/node/commands/screenshotMatcher/utils.ts
index f70feffc9..fb3645ae0 100644
--- a/packages/browser/src/node/commands/screenshotMatcher/utils.ts
+++ b/packages/browser/src/node/commands/screenshotMatcher/utils.ts
@@ -1,3 +1,6 @@
+// Note: this augments `screenshotOptions` types
+import type {} from '@vitest/browser-playwright'
+
import type { BrowserCommandContext, BrowserConfigOptions } from 'vitest/node'
import type { ScreenshotMatcherOptions } from '../../../../context'
import type { ScreenshotMatcherArguments } from '../../../shared/screenshotMatcher/types'
@@ -29,6 +32,7 @@ const defaultOptions = {
scale: 'device',
},
timeout: 5_000,
+ strict: true,
resolveDiffPath: ({
arg,
ext,
diff --git a/test/browser/test/findElement.test.ts b/test/browser/test/findElement.test.ts
new file mode 100644
index 000000000..1a211f9d2
--- /dev/null
+++ b/test/browser/test/findElement.test.ts
@@ -0,0 +1,110 @@
+import { beforeEach, expect, test, vi } from 'vitest'
+import { page } from 'vitest/browser'
+
+beforeEach(() => {
+ document.body.innerHTML = ''
+})
+
+test('locator.findElement can find the element if it exists', async () => {
+ const button = createButton()
+
+ const element = await page.getByRole('button').findElement()
+ expect(element).toBeInTheDocument()
+ expect(button).toBe(element)
+})
+
+test('locator.findElement can find the element if it appears', async () => {
+ let button: HTMLButtonElement
+
+ setTimeout(() => {
+ button = createButton()
+ }, 50)
+
+ const element = await page.getByRole('button').findElement()
+ expect(element).toBeInTheDocument()
+ expect(button).toBe(element)
+})
+
+test('locator.findElement fails if it cannot find the element', async () => {
+ const locator = page.getByRole('button')
+ const elementsSpy = vi.spyOn(locator, 'elements')
+ await expect(() => {
+ return locator.findElement({ timeout: 100 })
+ }).rejects.toThrow('Cannot find element with locator: getByRole(\'button\')')
+ // Normally it would be 5:
+ // Immidiate, 0 (next tick), 20, 50, 100
+ // But on CI it can be less because resources are limited
+ expect(elementsSpy.mock.calls.length).toBeGreaterThanOrEqual(3)
+})
+
+test('locator.findElement fails if there are multiple elements by default', async () => {
+ createButton()
+ createButton()
+
+ await expect(
+ () => page.getByRole('button').findElement(),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`
+ [Error: strict mode violation: getByRole('button') resolved to 2 elements:
+ 1) aka getByRole('button').first()
+ 2) aka getByRole('button').nth(1)
+ ]
+ `)
+})
+
+test('locator.findElement fails if there are multiple elements if strict mode is specified', async () => {
+ createButton()
+ createButton()
+
+ await expect(
+ () => page.getByRole('button').findElement({ strict: true }),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`
+ [Error: strict mode violation: getByRole('button') resolved to 2 elements:
+ 1) aka getByRole('button').first()
+ 2) aka getByRole('button').nth(1)
+ ]
+ `)
+})
+
+test('locator.findElement fails if multiple elements appear later with strict mode', async () => {
+ setTimeout(() => {
+ createButton()
+ createButton()
+ }, 50)
+
+ await expect(
+ () => page.getByRole('button').findElement(),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`
+ [Error: strict mode violation: getByRole('button') resolved to 2 elements:
+ 1) aka getByRole('button').first()
+ 2) aka getByRole('button').nth(1)
+ ]
+ `)
+})
+
+test('locator.findElement returns the first button if strict is disabled', async () => {
+ const button = createButton()
+ createButton()
+
+ const element = await page.getByRole('button').findElement({ strict: false })
+ expect(element).toBeInTheDocument()
+ expect(button).toBe(element)
+})
+
+test('locator.findElement returns the first button if strict is disabled after element appears', async () => {
+ let button: HTMLButtonElement
+
+ setTimeout(() => {
+ button = createButton()
+ createButton()
+ }, 50)
+
+ const element = await page.getByRole('button').findElement({ strict: false })
+ expect(element).toBeInTheDocument()
+ expect(button).toBe(element)
+})
+
+function createButton() {
+ const button = document.createElement('button')
+ document.body.append(button)
+ return button
+}
diff --git a/test/browser/test/userEvent.test.ts b/test/browser/test/userEvent.test.ts
index 46b8de159..88c692f5c 100644
--- a/test/browser/test/userEvent.test.ts
+++ b/test/browser/test/userEvent.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
-import { userEvent as _uE, server } from 'vitest/browser'
+import { userEvent as _uE, page, server } from 'vitest/browser'
import '../src/button.css'
beforeEach(() => {
@@ -157,6 +157,18 @@ describe('userEvent.click', () => {
y: expect.closeTo(150, -1),
})
})
+
+ test('click throws an error with multiple elements', async () => {
+ const button1 = document.createElement('button')
+ const button2 = document.createElement('button')
+ document.body.append(button1, button2)
+
+ await expect(() => page.getByRole('button').click()).rejects.toThrow(
+ `strict mode violation: getByRole('button') resolved to 2 elements:\n`
+ + ` 1) aka getByRole('button').first()\n`
+ + ` 2) aka getByRole('button').nth(1)`,
+ )
+ })
})
describe('userEvent.dblClick', () => {
@@ -193,6 +205,18 @@ describe('userEvent.dblClick', () => {
expect(onClick).not.toHaveBeenCalled()
expect(dblClick).not.toHaveBeenCalled()
})
+
+ test('double click throws an error with multiple elements', async () => {
+ const button1 = document.createElement('button')
+ const button2 = document.createElement('button')
+ document.body.append(button1, button2)
+
+ await expect(() => page.getByRole('button').dblClick()).rejects.toThrow(
+ `strict mode violation: getByRole('button') resolved to 2 elements:\n`
+ + ` 1) aka getByRole('button').first()\n`
+ + ` 2) aka getByRole('button').nth(1)`,
+ )
+ })
})
describe('userEvent.tripleClick', () => {
@@ -239,6 +263,18 @@ describe('userEvent.tripleClick', () => {
expect(dblClick).not.toHaveBeenCalled()
expect(tripleClick).not.toHaveBeenCalled()
})
+
+ test('triple click throws an error with multiple elements', async () => {
+ const button1 = document.createElement('button')
+ const button2 = document.createElement('button')
+ document.body.append(button1, button2)
+
+ await expect(() => page.getByRole('button').tripleClick()).rejects.toThrow(
+ `strict mode violation: getByRole('button') resolved to 2 elements:\n`
+ + ` 1) aka getByRole('button').first()\n`
+ + ` 2) aka getByRole('button').nth(1)`,
+ )
+ })
})
describe('userEvent.hover, userEvent.unhover', () => {
@@ -276,6 +312,18 @@ describe('userEvent.hover, userEvent.unhover', () => {
expect(mouseEntered).toBe(false)
})
+ test('hover throws an error with multiple elements', async () => {
+ const button1 = document.createElement('button')
+ const button2 = document.createElement('button')
+ document.body.append(button1, button2)
+
+ await expect(() => page.getByRole('button').hover()).rejects.toThrow(
+ `strict mode violation: getByRole('button') resolved to 2 elements:\n`
+ + ` 1) aka getByRole('button').first()\n`
+ + ` 2) aka getByRole('button').nth(1)`,
+ )
+ })
+
test.runIf(server.provider === 'playwright')('hover, unhover correctly pass options', async () => {
interface ModifiersDetected { shift: boolean; control: boolean }
type ModifierKeys = 'Shift' | 'Control' | 'Alt' | 'ControlOrMeta' | 'Meta'
@@ -409,6 +457,30 @@ const inputLike = [
},
]
+test('type throws an error with multiple elements', async () => {
+ const button1 = document.createElement('button')
+ const button2 = document.createElement('button')
+ document.body.append(button1, button2)
+
+ await expect(() => userEvent.type(page.getByRole('button'), 'Hello World')).rejects.toThrow(
+ `strict mode violation: getByRole('button') resolved to 2 elements:\n`
+ + ` 1) aka getByRole('button').first()\n`
+ + ` 2) aka getByRole('button').nth(1)`,
+ )
+})
+
+test('fill throws an error with multiple elements', async () => {
+ const button1 = document.createElement('button')
+ const button2 = document.createElement('button')
+ document.body.append(button1, button2)
+
+ await expect(() => page.getByRole('button').fill('Hello World')).rejects.toThrow(
+ `strict mode violation: getByRole('button') resolved to 2 elements:\n`
+ + ` 1) aka getByRole('button').first()\n`
+ + ` 2) aka getByRole('button').nth(1)`,
+ )
+})
+
describe.each(inputLike)('userEvent.type', (getElement) => {
test('types into an input', async () => {
const { input, keydown, keyup, value } = createTextInput()
@@ -814,7 +886,19 @@ describe.each([
// return { select, options: [option1, option2] }
// },
// ],
-])('selectOptions in "%s" works correctly', (_, createSelect) => {
+])('selectOptions in "%s" works correctly', (name, createSelect) => {
+ test(`${name} throws an error with multiple elements`, async () => {
+ const button1 = document.createElement('button')
+ const button2 = document.createElement('button')
+ document.body.append(button1, button2)
+
+ await expect(() => page.getByRole('button').selectOptions('Hello World')).rejects.toThrow(
+ `strict mode violation: getByRole('button') resolved to 2 elements:\n`
+ + ` 1) aka getByRole('button').first()\n`
+ + ` 2) aka getByRole('button').nth(1)`,
+ )
+ })
+
test('can select a single primitive value', async () => {
const { select } = createSelect()