From edff90aab5974b3f724f72ab045b8c6e4c910d33 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 16 Feb 2026 18:09:44 +0100 Subject: [PATCH 01/17] fix(browser): enable strict mode in webdriverio and preview --- packages/browser-preview/src/locators.ts | 41 ++++++++----- packages/browser-webdriverio/src/locators.ts | 55 ++++++++++++++--- .../src/client/tester/locators/index.ts | 61 ++++++++++++++++++- .../browser/src/client/tester/tester-utils.ts | 6 +- 4 files changed, 134 insertions(+), 29 deletions(-) diff --git a/packages/browser-preview/src/locators.ts b/packages/browser-preview/src/locators.ts index 8217002bbb96..da291a8f8459 100644 --- a/packages/browser-preview/src/locators.ts +++ b/packages/browser-preview/src/locators.ts @@ -26,40 +26,49 @@ class PreviewLocator extends Locator { return selectors.join(', ') } - click(): Promise { - return userEvent.click(this.element()) + async click(): Promise { + const element = await this.waitForElement() + return userEvent.click(element) } - dblClick(): Promise { - return userEvent.dblClick(this.element()) + async dblClick(): Promise { + const element = await this.waitForElement() + return userEvent.dblClick(element) } - tripleClick(): Promise { - return userEvent.tripleClick(this.element()) + async tripleClick(): Promise { + const element = await this.waitForElement() + return userEvent.tripleClick(element) } - hover(): Promise { - return userEvent.hover(this.element()) + async hover(): Promise { + const element = await this.waitForElement() + return userEvent.hover(element) } - unhover(): Promise { - return userEvent.unhover(this.element()) + async unhover(): Promise { + const element = await this.waitForElement() + return userEvent.unhover(element) } async fill(text: string): Promise { - return userEvent.fill(this.element(), text) + const element = await this.waitForElement() + return userEvent.fill(element, text) } async upload(file: string | string[] | File | File[]): Promise { - return userEvent.upload(this.element(), file) + const element = await this.waitForElement() + return userEvent.upload(element, file) } - selectOptions(options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise { - return userEvent.selectOptions(this.element(), options) + async selectOptions(options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise { + const element = await this.waitForElement() + return userEvent.selectOptions(element, options) } - clear(): Promise { - return userEvent.clear(this.element()) + async clear(): Promise { + const element = await this.waitForElement() + return userEvent.clear(element) } protected locator(selector: string) { diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index 681304b90929..a5a51b2b5da9 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -1,8 +1,13 @@ import type { + LocatorScreenshotOptions, + UserEventClearOptions, UserEventClickOptions, UserEventDragAndDropOptions, + UserEventFillOptions, UserEventHoverOptions, UserEventSelectOptions, + UserEventUploadOptions, + UserEventWheelOptions, } from 'vitest/browser' import { convertElementToCssSelector, @@ -41,34 +46,70 @@ class WebdriverIOLocator extends Locator { return (hasShadowRoot ? '>>>' : '') + newSelectors.join(', ') } - public override click(options?: UserEventClickOptions): Promise { + public override async click(options?: UserEventClickOptions): Promise { + await this.waitForElement() return super.click(processClickOptions(options)) } - public override dblClick(options?: UserEventClickOptions): Promise { + public override async dblClick(options?: UserEventClickOptions): Promise { + await this.waitForElement() return super.dblClick(processClickOptions(options)) } - public override tripleClick(options?: UserEventClickOptions): Promise { + public override async tripleClick(options?: UserEventClickOptions): Promise { + await this.waitForElement() return super.tripleClick(processClickOptions(options)) } - public selectOptions( + public async selectOptions( value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], options?: UserEventSelectOptions, ): Promise { - const values = getWebdriverioSelectOptions(this.element(), value) + const element = await this.waitForElement() + const values = getWebdriverioSelectOptions(element, value) return this.triggerCommand('__vitest_selectOptions', this.selector, values, options) } - public override hover(options?: UserEventHoverOptions): Promise { + public override async hover(options?: UserEventHoverOptions): Promise { + await this.waitForElement() return super.hover(processHoverOptions(options)) } - public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { + public override async unhover(options?: UserEventHoverOptions): Promise { + await this.waitForElement() + return super.unhover(options) + } + + public override async dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { + await this.waitForElement() return super.dropTo(target, processDragAndDropOptions(options)) } + public override async wheel(options: UserEventWheelOptions): Promise { + await this.waitForElement() + return super.wheel(options) + } + + public override async clear(options?: UserEventClearOptions): Promise { + await this.waitForElement() + return super.clear(options) + } + + public override async fill(text: string, options?: UserEventFillOptions): Promise { + await this.waitForElement() + return super.fill(text, options) + } + + public override async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise { + await this.waitForElement() + return super.upload(files, options) + } + + public override async screenshot(options?: LocatorScreenshotOptions): Promise { + await this.waitForElement() + return super.screenshot(options) + } + protected locator(selector: string) { return new WebdriverIOLocator(`${this._pwSelector} >> ${selector}`, this._container) } diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index ec9a2f9eb4b2..ebcd3e5531bc 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -26,7 +26,7 @@ import { import { page, server, utils } from 'vitest/browser' import { __INTERNAL } 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 { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils' export { @@ -41,6 +41,8 @@ export { __INTERNAL._asLocator = asLocator +const now = Date.now + // we prefer using playwright locators because they are more powerful and support Shadow DOM export const selectorEngine: Ivya = Ivya.create({ browser: ((name: string) => { @@ -293,6 +295,43 @@ export abstract class Locator { return this.selector } + // TODO: make public at one point? + protected async waitForElement(options_: { + timeout?: number + } = {}): Promise { + const options = processTimeoutOptions(options_) + const timeout = options?.timeout + const intervals = [0, 20, 50, 100, 100, 500] + const startTime = now() + let intervalIndex = 0 + return new Promise((resolve, reject) => { + const check = () => { + const elements = this.elements() + if (elements.length === 1) { + resolve(elements[0]) + return + } + const elapsed = now() - startTime + const isLastCall = timeout != null && elapsed >= timeout + if (isLastCall) { + if (elements.length > 1) { + reject(createStrictModeViolationError(this._pwSelector || this.selector, elements)) + } + else { + reject(utils.getElementError(this._pwSelector || this.selector, this._container || document.body)) + } + return + } + const interval = intervals[Math.min(intervalIndex++, intervals.length - 1)] + const nextInterval = timeout != null + ? Math.min(interval, timeout - elapsed) + : interval + setTimeout(check, nextInterval) + } + check() + }) + } + protected triggerCommand(command: string, ...args: any[]): Promise { const commands = getBrowserState().commands return ensureAwaited(error => commands.triggerCommand( @@ -302,3 +341,23 @@ export abstract class Locator { )) } } + +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 2c2e9df2dfc3..fcc28a432cd6 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -2,8 +2,6 @@ import type { Locator, UserEventWheelDeltaOptions, UserEventWheelOptions } from 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)) { @@ -142,12 +140,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_ } From 49cc4fb70b384216522c28aa9cfc19ed66bf6d17 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 16 Feb 2026 18:33:53 +0100 Subject: [PATCH 02/17] fix: allow timeout option in every interactive action --- packages/browser-preview/src/locators.ts | 45 +++++++++++------- packages/browser-webdriverio/src/locators.ts | 24 +++++----- packages/browser/context.d.ts | 41 ++++++++++++---- .../src/client/tester/locators/index.ts | 47 +++++++++---------- 4 files changed, 92 insertions(+), 65 deletions(-) diff --git a/packages/browser-preview/src/locators.ts b/packages/browser-preview/src/locators.ts index da291a8f8459..2a7fcce8588d 100644 --- a/packages/browser-preview/src/locators.ts +++ b/packages/browser-preview/src/locators.ts @@ -1,3 +1,4 @@ +import type { UserEventClearOptions, UserEventClickOptions, UserEventFillOptions, UserEventHoverOptions, UserEventSelectOptions, UserEventUploadOptions, UserEventWheelOptions } from 'vitest/browser' import { convertElementToCssSelector, getByAltTextSelector, @@ -26,48 +27,56 @@ class PreviewLocator extends Locator { return selectors.join(', ') } - async click(): Promise { - const element = await this.waitForElement() + async click(options?: UserEventClickOptions): Promise { + const element = await this.waitForElement(options) return userEvent.click(element) } - async dblClick(): Promise { - const element = await this.waitForElement() + async dblClick(options?: UserEventClickOptions): Promise { + const element = await this.waitForElement(options) return userEvent.dblClick(element) } - async tripleClick(): Promise { - const element = await this.waitForElement() + async tripleClick(options?: UserEventClickOptions): Promise { + const element = await this.waitForElement(options) return userEvent.tripleClick(element) } - async hover(): Promise { - const element = await this.waitForElement() + async hover(options?: UserEventHoverOptions): Promise { + const element = await this.waitForElement(options) return userEvent.hover(element) } - async unhover(): Promise { - const element = await this.waitForElement() + async unhover(options?: UserEventHoverOptions): Promise { + const element = await this.waitForElement(options) return userEvent.unhover(element) } - async fill(text: string): Promise { - const element = await this.waitForElement() + async fill(text: string, options?: UserEventFillOptions): Promise { + const element = await this.waitForElement(options) return userEvent.fill(element, text) } - async upload(file: string | string[] | File | File[]): Promise { - const element = await this.waitForElement() + async upload(file: string | string[] | File | File[], options?: UserEventUploadOptions): Promise { + const element = await this.waitForElement(options) return userEvent.upload(element, file) } - async selectOptions(options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise { - const element = await this.waitForElement() + async wheel(options: UserEventWheelOptions): Promise { + const element = await this.waitForElement(options) + return userEvent.wheel(element, options) + } + + async selectOptions( + options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[], + settings?: UserEventSelectOptions, + ): Promise { + const element = await this.waitForElement(settings) return userEvent.selectOptions(element, options) } - async clear(): Promise { - const element = await this.waitForElement() + async clear(options?: UserEventClearOptions): Promise { + const element = await this.waitForElement(options) return userEvent.clear(element) } diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index a5a51b2b5da9..edb7ed3e1f7b 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -47,17 +47,17 @@ class WebdriverIOLocator extends Locator { } public override async click(options?: UserEventClickOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.click(processClickOptions(options)) } public override async dblClick(options?: UserEventClickOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.dblClick(processClickOptions(options)) } public override async tripleClick(options?: UserEventClickOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.tripleClick(processClickOptions(options)) } @@ -65,48 +65,48 @@ class WebdriverIOLocator extends Locator { value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], options?: UserEventSelectOptions, ): Promise { - const element = await this.waitForElement() + const element = await this.waitForElement(options) const values = getWebdriverioSelectOptions(element, value) return this.triggerCommand('__vitest_selectOptions', this.selector, values, options) } public override async hover(options?: UserEventHoverOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.hover(processHoverOptions(options)) } public override async unhover(options?: UserEventHoverOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.unhover(options) } public override async dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.dropTo(target, processDragAndDropOptions(options)) } public override async wheel(options: UserEventWheelOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.wheel(options) } public override async clear(options?: UserEventClearOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.clear(options) } public override async fill(text: string, options?: UserEventFillOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.fill(text, options) } public override async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.upload(files, options) } public override async screenshot(options?: LocatorScreenshotOptions): Promise { - await this.waitForElement() + await this.waitForElement(options) return super.screenshot(options) } diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index c09dd565de9a..67e0b4208fd2 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -354,15 +354,33 @@ export interface UserEvent { dragAndDrop: (source: Element | Locator, target: Element | Locator, options?: UserEventDragAndDropOptions) => Promise } -export interface UserEventFillOptions {} -export interface UserEventHoverOptions {} -export interface UserEventSelectOptions {} -export interface UserEventClickOptions {} -export interface UserEventClearOptions {} -export interface UserEventDoubleClickOptions {} -export interface UserEventTripleClickOptions {} -export interface UserEventDragAndDropOptions {} -export interface UserEventUploadOptions {} +export interface UserEventFillOptions { + timeout?: number +} +export interface UserEventHoverOptions { + timeout?: number +} +export interface UserEventSelectOptions { + timeout?: number +} +export interface UserEventClickOptions { + timeout?: number +} +export interface UserEventClearOptions { + timeout?: number +} +export interface UserEventDoubleClickOptions { + timeout?: number +} +export interface UserEventTripleClickOptions { + timeout?: number +} +export interface UserEventDragAndDropOptions { + timeout?: number +} +export interface UserEventUploadOptions { + timeout?: number +} /** * Base options shared by all wheel event configurations. @@ -376,6 +394,7 @@ export interface UserEventWheelBaseOptions { * Useful for triggering multiple scroll steps in a single call. */ times?: number + timeout?: number } /** @@ -479,7 +498,9 @@ export interface LocatorByRoleOptions extends LocatorOptions { selected?: boolean } -interface LocatorScreenshotOptions extends Omit {} +interface LocatorScreenshotOptions extends Omit { + timeout?: number +} export interface LocatorSelectors { /** diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index ebcd3e5531bc..cd91cb90b234 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -42,6 +42,11 @@ export { __INTERNAL._asLocator = asLocator const now = Date.now +const waitForIntervals = [0, 20, 50, 100, 100, 500] + +function sleep(ms: number): Promise { + 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({ @@ -301,35 +306,27 @@ export abstract class Locator { } = {}): Promise { const options = processTimeoutOptions(options_) const timeout = options?.timeout - const intervals = [0, 20, 50, 100, 100, 500] const startTime = now() let intervalIndex = 0 - return new Promise((resolve, reject) => { - const check = () => { - const elements = this.elements() - if (elements.length === 1) { - resolve(elements[0]) - return - } - const elapsed = now() - startTime - const isLastCall = timeout != null && elapsed >= timeout - if (isLastCall) { - if (elements.length > 1) { - reject(createStrictModeViolationError(this._pwSelector || this.selector, elements)) - } - else { - reject(utils.getElementError(this._pwSelector || this.selector, this._container || document.body)) - } - return + while (true) { + const elements = this.elements() + if (elements.length === 1) { + return elements[0] + } + const elapsed = now() - startTime + const isLastCall = timeout != null && elapsed >= timeout + if (isLastCall) { + if (elements.length > 1) { + throw createStrictModeViolationError(this._pwSelector || this.selector, elements) } - const interval = intervals[Math.min(intervalIndex++, intervals.length - 1)] - const nextInterval = timeout != null - ? Math.min(interval, timeout - elapsed) - : interval - setTimeout(check, nextInterval) + throw utils.getElementError(this._pwSelector || this.selector, this._container || document.body) } - check() - }) + 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 { From d56a4f234638efc3f6c90ca265b5ef8103a548e2 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 16 Feb 2026 18:58:26 +0100 Subject: [PATCH 03/17] chore: update types --- packages/browser-preview/src/preview.ts | 36 ++++++++++++++++ .../browser-webdriverio/src/webdriverio.ts | 34 +++++++++++++-- packages/browser/context.d.ts | 41 +++++-------------- 3 files changed, 77 insertions(+), 34 deletions(-) diff --git a/packages/browser-preview/src/preview.ts b/packages/browser-preview/src/preview.ts index 07bd989d66f3..911e79845297 100644 --- a/packages/browser-preview/src/preview.ts +++ b/packages/browser-preview/src/preview.ts @@ -60,3 +60,39 @@ export class PreviewBrowserProvider implements BrowserProvider { async close(): Promise {} } + +declare module 'vitest/browser' { + export interface UserEventClickOptions { + timeout?: number + } + export interface UserEventHoverOptions { + timeout?: number + } + export interface UserEventDragAndDropOptions { + timeout?: number + } + export interface UserEventFillOptions { + timeout?: number + } + export interface UserEventSelectOptions { + timeout?: number + } + export interface UserEventClearOptions { + timeout?: number + } + export interface UserEventDoubleClickOptions { + timeout?: number + } + export interface UserEventTripleClickOptions { + timeout?: number + } + export interface UserEventUploadOptions { + timeout?: number + } + export interface UserEventWheelBaseOptions { + timeout?: number + } + export interface LocatorScreenshotOptions { + timeout?: number + } +} diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index 7f86aa919ca1..bcd3afca6f8e 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -288,14 +288,42 @@ export class WebdriverBrowserProvider implements BrowserProvider { } declare module 'vitest/browser' { - export interface UserEventClickOptions extends Partial {} - export interface UserEventHoverOptions extends MoveToOptions {} - + export interface UserEventClickOptions extends Partial { + timeout?: number + } + export interface UserEventHoverOptions extends MoveToOptions { + timeout?: number + } export interface UserEventDragAndDropOptions extends DragAndDropOptions { sourceX?: number sourceY?: number targetX?: number targetY?: number + timeout?: number + } + export interface UserEventFillOptions { + timeout?: number + } + export interface UserEventSelectOptions { + timeout?: number + } + export interface UserEventClearOptions { + timeout?: number + } + export interface UserEventDoubleClickOptions { + timeout?: number + } + export interface UserEventTripleClickOptions { + timeout?: number + } + export interface UserEventUploadOptions { + timeout?: number + } + export interface UserEventWheelBaseOptions { + timeout?: number + } + export interface LocatorScreenshotOptions { + timeout?: number } } diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 67e0b4208fd2..c09dd565de9a 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -354,33 +354,15 @@ export interface UserEvent { dragAndDrop: (source: Element | Locator, target: Element | Locator, options?: UserEventDragAndDropOptions) => Promise } -export interface UserEventFillOptions { - timeout?: number -} -export interface UserEventHoverOptions { - timeout?: number -} -export interface UserEventSelectOptions { - timeout?: number -} -export interface UserEventClickOptions { - timeout?: number -} -export interface UserEventClearOptions { - timeout?: number -} -export interface UserEventDoubleClickOptions { - timeout?: number -} -export interface UserEventTripleClickOptions { - timeout?: number -} -export interface UserEventDragAndDropOptions { - timeout?: number -} -export interface UserEventUploadOptions { - timeout?: number -} +export interface UserEventFillOptions {} +export interface UserEventHoverOptions {} +export interface UserEventSelectOptions {} +export interface UserEventClickOptions {} +export interface UserEventClearOptions {} +export interface UserEventDoubleClickOptions {} +export interface UserEventTripleClickOptions {} +export interface UserEventDragAndDropOptions {} +export interface UserEventUploadOptions {} /** * Base options shared by all wheel event configurations. @@ -394,7 +376,6 @@ export interface UserEventWheelBaseOptions { * Useful for triggering multiple scroll steps in a single call. */ times?: number - timeout?: number } /** @@ -498,9 +479,7 @@ export interface LocatorByRoleOptions extends LocatorOptions { selected?: boolean } -interface LocatorScreenshotOptions extends Omit { - timeout?: number -} +interface LocatorScreenshotOptions extends Omit {} export interface LocatorSelectors { /** From 00c382c8d42b248749e99d291e7d398a91276525 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 16 Feb 2026 19:37:59 +0100 Subject: [PATCH 04/17] feat: add `strict` option --- packages/browser-preview/src/preview.ts | 11 +++++++++++ packages/browser-webdriverio/src/webdriverio.ts | 11 +++++++++++ packages/browser/src/client/tester/locators/index.ts | 5 +++++ 3 files changed, 27 insertions(+) diff --git a/packages/browser-preview/src/preview.ts b/packages/browser-preview/src/preview.ts index 911e79845297..3a2879f3fc22 100644 --- a/packages/browser-preview/src/preview.ts +++ b/packages/browser-preview/src/preview.ts @@ -64,35 +64,46 @@ export class PreviewBrowserProvider implements BrowserProvider { declare module 'vitest/browser' { export interface UserEventClickOptions { timeout?: number + strict?: boolean } export interface UserEventHoverOptions { timeout?: number + strict?: boolean } export interface UserEventDragAndDropOptions { timeout?: number + strict?: boolean } export interface UserEventFillOptions { timeout?: number + strict?: boolean } export interface UserEventSelectOptions { timeout?: number + strict?: boolean } export interface UserEventClearOptions { timeout?: number + strict?: boolean } export interface UserEventDoubleClickOptions { timeout?: number + strict?: boolean } export interface UserEventTripleClickOptions { timeout?: number + strict?: boolean } export interface UserEventUploadOptions { timeout?: number + strict?: boolean } export interface UserEventWheelBaseOptions { timeout?: number + strict?: boolean } export interface LocatorScreenshotOptions { timeout?: number + strict?: boolean } } diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index bcd3afca6f8e..1667414a1e46 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -290,9 +290,11 @@ export class WebdriverBrowserProvider implements BrowserProvider { declare module 'vitest/browser' { export interface UserEventClickOptions extends Partial { timeout?: number + strict?: boolean } export interface UserEventHoverOptions extends MoveToOptions { timeout?: number + strict?: boolean } export interface UserEventDragAndDropOptions extends DragAndDropOptions { sourceX?: number @@ -300,30 +302,39 @@ declare module 'vitest/browser' { targetX?: number targetY?: number timeout?: number + strict?: boolean } export interface UserEventFillOptions { timeout?: number + strict?: boolean } export interface UserEventSelectOptions { timeout?: number + strict?: boolean } export interface UserEventClearOptions { timeout?: number + strict?: boolean } export interface UserEventDoubleClickOptions { timeout?: number + strict?: boolean } export interface UserEventTripleClickOptions { timeout?: number + strict?: boolean } export interface UserEventUploadOptions { timeout?: number + strict?: boolean } export interface UserEventWheelBaseOptions { timeout?: number + strict?: boolean } export interface LocatorScreenshotOptions { timeout?: number + strict?: boolean } } diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index cd91cb90b234..daed67bce3bc 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -302,10 +302,12 @@ export abstract class Locator { // TODO: make public at one point? protected async waitForElement(options_: { + strict?: boolean timeout?: number } = {}): Promise { const options = processTimeoutOptions(options_) const timeout = options?.timeout + const strict = options?.strict ?? true const startTime = now() let intervalIndex = 0 while (true) { @@ -313,6 +315,9 @@ export abstract class Locator { if (elements.length === 1) { return elements[0] } + if (!strict && elements.length > 1) { + return elements[0] + } const elapsed = now() - startTime const isLastCall = timeout != null && elapsed >= timeout if (isLastCall) { From 4b2c3cdd3e0ed236c17fe9709f7fc0cdf8bfe2cd Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 16 Feb 2026 20:18:21 +0100 Subject: [PATCH 05/17] chore: cleanup --- packages/browser-preview/src/locators.ts | 10 +- packages/browser-webdriverio/src/locators.ts | 93 +++++++++++++------ .../src/client/tester/locators/index.ts | 11 ++- 3 files changed, 84 insertions(+), 30 deletions(-) diff --git a/packages/browser-preview/src/locators.ts b/packages/browser-preview/src/locators.ts index 2a7fcce8588d..a5fecb028c90 100644 --- a/packages/browser-preview/src/locators.ts +++ b/packages/browser-preview/src/locators.ts @@ -1,4 +1,12 @@ -import type { UserEventClearOptions, UserEventClickOptions, UserEventFillOptions, UserEventHoverOptions, UserEventSelectOptions, UserEventUploadOptions, UserEventWheelOptions } from 'vitest/browser' +import type { + UserEventClearOptions, + UserEventClickOptions, + UserEventFillOptions, + UserEventHoverOptions, + UserEventSelectOptions, + UserEventUploadOptions, + UserEventWheelOptions, +} from 'vitest/browser' import { convertElementToCssSelector, getByAltTextSelector, diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index edb7ed3e1f7b..48a28b200ae0 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -30,6 +30,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) { + const pwSelector = selectorEngine.generateSelectorSimple(element) + const cssSelector = convertElementToCssSelector(element) + return new ElementWebdriverIOLocator(cssSelector, pwSelector, element) + } + override get selector(): string { const selectors = this.elements().map(element => convertElementToCssSelector(element)) if (!selectors.length) { @@ -47,18 +54,18 @@ class WebdriverIOLocator extends Locator { } public override async click(options?: UserEventClickOptions): Promise { - await this.waitForElement(options) - return super.click(processClickOptions(options)) + const element = await this.waitForElement(options) + return this.withElement(element).click(processClickOptions(options)) } public override async dblClick(options?: UserEventClickOptions): Promise { - await this.waitForElement(options) - return super.dblClick(processClickOptions(options)) + const element = await this.waitForElement(options) + return this.withElement(element).dblClick(processClickOptions(options)) } public override async tripleClick(options?: UserEventClickOptions): Promise { - await this.waitForElement(options) - return super.tripleClick(processClickOptions(options)) + const element = await this.waitForElement(options) + return this.withElement(element).tripleClick(processClickOptions(options)) } public async selectOptions( @@ -67,47 +74,59 @@ class WebdriverIOLocator extends Locator { ): Promise { const element = await this.waitForElement(options) const values = getWebdriverioSelectOptions(element, value) - return this.triggerCommand('__vitest_selectOptions', this.selector, values, options) + return this.triggerCommand( + '__vitest_selectOptions', + convertElementToCssSelector(element), + values, + options, + ) } public override async hover(options?: UserEventHoverOptions): Promise { - await this.waitForElement(options) - return super.hover(processHoverOptions(options)) - } - - public override async unhover(options?: UserEventHoverOptions): Promise { - await this.waitForElement(options) - return super.unhover(options) + const element = await this.waitForElement(options) + return this.withElement(element).hover(processHoverOptions(options)) } public override async dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { - await this.waitForElement(options) - return super.dropTo(target, processDragAndDropOptions(options)) + const [element, targetElement] = await Promise.all([ + this.waitForElement(options), + // @ts-expect-error protected API + target.waitForElement({ + timeout: options?.timeout, + strict: options?.strict, + }), + ]) + return this.triggerCommand( + '__vitest_dragAndDrop', + convertElementToCssSelector(element), + convertElementToCssSelector(targetElement), + processDragAndDropOptions(options), + ) } public override async wheel(options: UserEventWheelOptions): Promise { - await this.waitForElement(options) - return super.wheel(options) + const element = await this.waitForElement(options) + return this.withElement(element).wheel(options) } public override async clear(options?: UserEventClearOptions): Promise { - await this.waitForElement(options) - return super.clear(options) + const element = await this.waitForElement(options) + return this.withElement(element).clear(options) } public override async fill(text: string, options?: UserEventFillOptions): Promise { - await this.waitForElement(options) - return super.fill(text, options) + const element = await this.waitForElement(options) + return this.withElement(element).fill(text, options) } public override async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise { - await this.waitForElement(options) - return super.upload(files, options) + const element = await this.waitForElement(options) + return this.withElement(element).upload(files, options) } public override async screenshot(options?: LocatorScreenshotOptions): Promise { - await this.waitForElement(options) - return super.screenshot(options) + const element = await this.waitForElement(options) + return this.withElement(element).screenshot(options) } protected locator(selector: string) { @@ -119,6 +138,28 @@ class WebdriverIOLocator extends Locator { } } +class ElementWebdriverIOLocator extends Locator { + constructor( + private _cssSelector: string, + 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/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index daed67bce3bc..61e42db4e220 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -24,7 +24,7 @@ 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, processTimeoutOptions, resolveUserEventWheelOptions } from '../tester-utils' @@ -45,7 +45,7 @@ const now = Date.now const waitForIntervals = [0, 20, 50, 100, 100, 500] function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise(resolve => getSafeTimers().setTimeout(resolve, ms)) } // we prefer using playwright locators because they are more powerful and support Shadow DOM @@ -146,7 +146,12 @@ export abstract class Locator { base64: bas64String.slice(bas64String.indexOf(',') + 1), } }) - return this.triggerCommand('__vitest_upload', this.selector, await Promise.all(filesPromise), options) + return this.triggerCommand( + '__vitest_upload', + this.selector, + await Promise.all(filesPromise), + options, + ) } public dropTo(target: Locator, options: UserEventDragAndDropOptions = {}): Promise { From dab7b31bbef426bad8e07462d03205293bbc93da Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 17 Feb 2026 13:45:10 +0100 Subject: [PATCH 06/17] test: add tests for strict error --- packages/browser-preview/src/preview.ts | 4 - packages/browser-webdriverio/src/locators.ts | 29 ++---- .../browser-webdriverio/src/webdriverio.ts | 6 -- packages/browser/src/client/tester/context.ts | 9 +- .../client/tester/expect/toMatchScreenshot.ts | 13 ++- .../src/client/tester/locators/index.ts | 8 +- .../browser/src/client/tester/tester-utils.ts | 12 ++- test/browser/test/userEvent.test.ts | 88 ++++++++++++++++++- 8 files changed, 123 insertions(+), 46 deletions(-) diff --git a/packages/browser-preview/src/preview.ts b/packages/browser-preview/src/preview.ts index 3a2879f3fc22..fdb33adb6150 100644 --- a/packages/browser-preview/src/preview.ts +++ b/packages/browser-preview/src/preview.ts @@ -70,10 +70,6 @@ declare module 'vitest/browser' { timeout?: number strict?: boolean } - export interface UserEventDragAndDropOptions { - timeout?: number - strict?: boolean - } export interface UserEventFillOptions { timeout?: number strict?: boolean diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index 48a28b200ae0..54aa44bb0b33 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -6,7 +6,6 @@ import type { UserEventFillOptions, UserEventHoverOptions, UserEventSelectOptions, - UserEventUploadOptions, UserEventWheelOptions, } from 'vitest/browser' import { @@ -88,20 +87,8 @@ class WebdriverIOLocator extends Locator { } public override async dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { - const [element, targetElement] = await Promise.all([ - this.waitForElement(options), - // @ts-expect-error protected API - target.waitForElement({ - timeout: options?.timeout, - strict: options?.strict, - }), - ]) - return this.triggerCommand( - '__vitest_dragAndDrop', - convertElementToCssSelector(element), - convertElementToCssSelector(targetElement), - processDragAndDropOptions(options), - ) + // playwright doesn't enforce a single element, it selects the first found one + return super.dropTo(target, processDragAndDropOptions(options)) } public override async wheel(options: UserEventWheelOptions): Promise { @@ -119,16 +106,14 @@ class WebdriverIOLocator extends Locator { return this.withElement(element).fill(text, options) } - public override async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).upload(files, options) - } - public override async screenshot(options?: LocatorScreenshotOptions): Promise { const element = await this.waitForElement(options) return this.withElement(element).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) } @@ -138,7 +123,11 @@ class WebdriverIOLocator extends Locator { } } +const kElementLocator = Symbol.for('$$vitest:locator-resolved') + class ElementWebdriverIOLocator extends Locator { + public [kElementLocator] = true + constructor( private _cssSelector: string, protected _pwSelector: string, diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index 1667414a1e46..82419cf5d909 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -301,8 +301,6 @@ declare module 'vitest/browser' { sourceY?: number targetX?: number targetY?: number - timeout?: number - strict?: boolean } export interface UserEventFillOptions { timeout?: number @@ -324,10 +322,6 @@ declare module 'vitest/browser' { timeout?: number strict?: boolean } - export interface UserEventUploadOptions { - timeout?: number - strict?: boolean - } export interface UserEventWheelBaseOptions { timeout?: number strict?: boolean diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 349dfc890928..431f3f3b5533 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) const { unreleased } = await triggerCommand<{ unreleased: string[] }>( '__vitest_type', [ @@ -326,9 +326,10 @@ export const page: BrowserPage = { const normalizedOptions = 'mask' in options ? { ...options, - mask: (options.mask as Array).map(convertToSelector), + mask: await Promise.all((options.mask as Array).map(convertToSelector)), } : options + const element = options.element ? await convertToSelector(options.element) : undefined return ensureAwaited(error => triggerCommand( '__vitest_screenshot', @@ -336,9 +337,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 64c23efbd762..3391d388c561 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.screenshotOptions && 'mask' in options.screenshotOptions + ? (options.screenshotOptions.mask as Array) + .map(convertToSelector) + : [], + ]) + 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 61e42db4e220..25878bcd202d 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -320,15 +320,15 @@ export abstract class Locator { if (elements.length === 1) { return elements[0] } - if (!strict && elements.length > 1) { + 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) { - if (elements.length > 1) { - throw createStrictModeViolationError(this._pwSelector || this.selector, elements) - } throw utils.getElementError(this._pwSelector || this.selector, this._container || document.body) } const interval = waitForIntervals[Math.min(intervalIndex++, waitForIntervals.length - 1)] diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index fcc28a432cd6..5c24bee4d10f 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -205,7 +205,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): Promise { if (!elementOrLocator) { throw new Error('Expected element or locator to be defined.') } @@ -213,7 +216,12 @@ 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 + } + // @ts-expect-error private method + const element = await elementOrLocator.waitForElement() + return convertElementToCssSelector(element) } throw new Error('Expected element or locator to be an instance of Element or Locator.') } diff --git a/test/browser/test/userEvent.test.ts b/test/browser/test/userEvent.test.ts index 46b8de159fc5..88c692f5c62b 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() From b1597c2e982ef63a8ac84659e4acabd5e38b5cc5 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 17 Feb 2026 14:58:18 +0100 Subject: [PATCH 07/17] fix: ensure awaited --- packages/browser-preview/src/preview.ts | 51 +++--------- packages/browser-webdriverio/src/locators.ts | 81 ++++++++++++------- .../browser-webdriverio/src/webdriverio.ts | 46 +++-------- 3 files changed, 75 insertions(+), 103 deletions(-) diff --git a/packages/browser-preview/src/preview.ts b/packages/browser-preview/src/preview.ts index fdb33adb6150..8387140df7b3 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' @@ -62,44 +63,14 @@ export class PreviewBrowserProvider implements BrowserProvider { } declare module 'vitest/browser' { - export interface UserEventClickOptions { - timeout?: number - strict?: boolean - } - export interface UserEventHoverOptions { - timeout?: number - strict?: boolean - } - export interface UserEventFillOptions { - timeout?: number - strict?: boolean - } - export interface UserEventSelectOptions { - timeout?: number - strict?: boolean - } - export interface UserEventClearOptions { - timeout?: number - strict?: boolean - } - export interface UserEventDoubleClickOptions { - timeout?: number - strict?: boolean - } - export interface UserEventTripleClickOptions { - timeout?: number - strict?: boolean - } - export interface UserEventUploadOptions { - timeout?: number - strict?: boolean - } - export interface UserEventWheelBaseOptions { - timeout?: number - strict?: boolean - } - export interface LocatorScreenshotOptions { - timeout?: number - strict?: boolean - } + 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 54aa44bb0b33..9f65ac13ac33 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -10,6 +10,7 @@ import type { } from 'vitest/browser' import { convertElementToCssSelector, + ensureAwaited, getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, @@ -30,10 +31,17 @@ class WebdriverIOLocator extends Locator { } // This exists to avoid calling `this.elements` in `this.selector`'s getter in interactive actions - private withElement(element: Element) { + private withElement(element: Element, error: Error | undefined) { const pwSelector = selectorEngine.generateSelectorSimple(element) const cssSelector = convertElementToCssSelector(element) - return new ElementWebdriverIOLocator(cssSelector, pwSelector, element) + return new ElementWebdriverIOLocator(cssSelector, error, pwSelector, element) + } + + private withError(error: Error | undefined, fn: () => T): T { + this._errorSource = error + const result = fn() + this._errorSource = undefined + return result } override get selector(): string { @@ -52,38 +60,48 @@ class WebdriverIOLocator extends Locator { return (hasShadowRoot ? '>>>' : '') + newSelectors.join(', ') } - public override async click(options?: UserEventClickOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).click(processClickOptions(options)) + public override click(options?: UserEventClickOptions): Promise { + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).click(processClickOptions(options)) + }) } public override async dblClick(options?: UserEventClickOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).dblClick(processClickOptions(options)) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).dblClick(processClickOptions(options)) + }) } public override async tripleClick(options?: UserEventClickOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).tripleClick(processClickOptions(options)) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).tripleClick(processClickOptions(options)) + }) } public async selectOptions( value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], options?: UserEventSelectOptions, ): Promise { - const element = await this.waitForElement(options) - const values = getWebdriverioSelectOptions(element, value) - return this.triggerCommand( - '__vitest_selectOptions', - convertElementToCssSelector(element), - values, - options, - ) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + const values = getWebdriverioSelectOptions(element, value) + return this.withError(error, () => this.triggerCommand( + '__vitest_selectOptions', + convertElementToCssSelector(element), + values, + options, + )) + }) } public override async hover(options?: UserEventHoverOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).hover(processHoverOptions(options)) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).hover(processHoverOptions(options)) + }) } public override async dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { @@ -92,23 +110,31 @@ class WebdriverIOLocator extends Locator { } public override async wheel(options: UserEventWheelOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).wheel(options) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).wheel(options) + }) } public override async clear(options?: UserEventClearOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).clear(options) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).clear(options) + }) } public override async fill(text: string, options?: UserEventFillOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).fill(text, options) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).fill(text, options) + }) } public override async screenshot(options?: LocatorScreenshotOptions): Promise { - const element = await this.waitForElement(options) - return this.withElement(element).screenshot(options) + return ensureAwaited(async (error) => { + const element = await this.waitForElement(options) + return this.withElement(element, error).screenshot(options) + }) } // playwright doesn't enforce a single element in upload @@ -130,6 +156,7 @@ class ElementWebdriverIOLocator extends Locator { constructor( private _cssSelector: string, + protected _errorSource: Error | undefined, protected _pwSelector: string, protected _container: Element, ) { diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index 82419cf5d909..467f3ee31369 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,48 +289,21 @@ export class WebdriverBrowserProvider implements BrowserProvider { } declare module 'vitest/browser' { - export interface UserEventClickOptions extends Partial { - timeout?: number - strict?: boolean - } - export interface UserEventHoverOptions extends MoveToOptions { - timeout?: number - strict?: boolean - } + 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 { - timeout?: number - strict?: boolean - } - export interface UserEventSelectOptions { - timeout?: number - strict?: boolean - } - export interface UserEventClearOptions { - timeout?: number - strict?: boolean - } - export interface UserEventDoubleClickOptions { - timeout?: number - strict?: boolean - } - export interface UserEventTripleClickOptions { - timeout?: number - strict?: boolean - } - export interface UserEventWheelBaseOptions { - timeout?: number - strict?: boolean - } - export interface LocatorScreenshotOptions { - timeout?: number - strict?: boolean - } + 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' { From ca6e7a4ec7d495c4556f268c92dde7f751afc053 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 17 Feb 2026 16:55:29 +0100 Subject: [PATCH 08/17] feat: expose waitForElement --- docs/api/browser/locators.md | 55 +++++++++++ packages/browser-webdriverio/src/locators.ts | 19 ++-- packages/browser/context.d.ts | 29 ++++++ .../src/client/tester/locators/index.ts | 94 ++++++++++++------- .../browser/src/client/tester/tester-utils.ts | 2 - 5 files changed, 149 insertions(+), 50 deletions(-) diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index f81e040f8bcc..7b845bbc9534 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -935,6 +935,61 @@ page.getByText('Hello').elements() // ✅ [HTMLElement, HTMLElement] page.getByText('Hello USA').elements() // ✅ [] ``` +### waitForElement 4.1.0 {#waitforelement} + +```ts +function waitForElement(options?: SelectorOptions): Promise +``` + +::: danger 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](#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 a single element is found. By default, this has the same timeout as 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').waitForElement() // ✅ HTMLDivElement +await page.getByText('World').waitForElement() // ✅ HTMLSpanElement +await page.getByText('Hello Germany').waitForElement() // ✅ HTMLDivElement +``` + +These locators will throw an error: + +```ts +// multiple elements match, strict mode rejects +await page.getByText('Hello').waitForElement() // ❌ +await page.getByText(/^Hello/).waitForElement() // ❌ + +// no matching element before timeout +await page.getByText('Hello USA').waitForElement() // ❌ +``` + +Using `strict: false` to allow multiple matches: + +```ts +// returns the first matching element instead of throwing +await page.getByText('Hello').waitForElement({ strict: false }) // ✅ HTMLDivElement +``` + ### all ```ts diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index 9f65ac13ac33..02da90e056f9 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -21,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' @@ -37,13 +38,6 @@ class WebdriverIOLocator extends Locator { return new ElementWebdriverIOLocator(cssSelector, error, pwSelector, element) } - private withError(error: Error | undefined, fn: () => T): T { - this._errorSource = error - const result = fn() - this._errorSource = undefined - return result - } - override get selector(): string { const selectors = this.elements().map(element => convertElementToCssSelector(element)) if (!selectors.length) { @@ -88,12 +82,11 @@ class WebdriverIOLocator extends Locator { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) const values = getWebdriverioSelectOptions(element, value) - return this.withError(error, () => this.triggerCommand( - '__vitest_selectOptions', - convertElementToCssSelector(element), - values, - options, - )) + return triggerCommandWithTrace({ + name: '__vitest_selectOptions', + arguments: [convertElementToCssSelector(element), values, options], + errorSource: error, + }) }) } diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index c09dd565de9a..b573368f8ea9 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -522,6 +522,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,6 +705,19 @@ export interface Locator extends LocatorSelectors { * @see {@link https://vitest.dev/api/browser/locators#filter} */ filter(options: LocatorOptions): Locator + /** + * Returns the HTML element matching the locator. + * This method will wait until only a single element appears in the DOM, but + * the strictness can be configured with options. + * + * **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#waitForElement} + */ + waitForElement(options?: SelectorOptions): Promise } export interface UserEventTabOptions { diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 25878bcd202d..12a76815da90 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, @@ -28,6 +29,7 @@ import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser' import { ensureAwaited, getBrowserState } from '../../utils' import { escapeForTextSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from '../tester-utils' +export { ensureAwaited } from '../../utils' export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils' export { getByAltTextSelector, @@ -72,6 +74,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, { @@ -95,8 +98,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 @@ -128,30 +135,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 { @@ -305,11 +313,7 @@ export abstract class Locator { return this.selector } - // TODO: make public at one point? - protected async waitForElement(options_: { - strict?: boolean - timeout?: number - } = {}): Promise { + public async waitForElement(options_: SelectorOptions = {}): Promise { const options = processTimeoutOptions(options_) const timeout = options?.timeout const strict = options?.strict ?? true @@ -340,15 +344,35 @@ export abstract class Locator { } 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[], diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 5c24bee4d10f..4ca844f3afef 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -128,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) @@ -219,7 +218,6 @@ export async function convertToSelector(elementOrLocator: Element | Locator): Pr if (provider === 'playwright' || kElementLocator in elementOrLocator) { return elementOrLocator.selector } - // @ts-expect-error private method const element = await elementOrLocator.waitForElement() return convertElementToCssSelector(element) } From 2e0731da1d2a9e15022907bc3ea3e73cf567835e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 18 Feb 2026 11:50:02 +0100 Subject: [PATCH 09/17] fix: make async into sync --- packages/browser-webdriverio/src/locators.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index 02da90e056f9..ef1178d22c7a 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -61,21 +61,21 @@ class WebdriverIOLocator extends Locator { }) } - public override async dblClick(options?: UserEventClickOptions): Promise { + public override dblClick(options?: UserEventClickOptions): Promise { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) return this.withElement(element, error).dblClick(processClickOptions(options)) }) } - public override async tripleClick(options?: UserEventClickOptions): Promise { + public override tripleClick(options?: UserEventClickOptions): Promise { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) return this.withElement(element, error).tripleClick(processClickOptions(options)) }) } - public async selectOptions( + public selectOptions( value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], options?: UserEventSelectOptions, ): Promise { @@ -90,40 +90,40 @@ class WebdriverIOLocator extends Locator { }) } - public override async hover(options?: UserEventHoverOptions): Promise { + public override hover(options?: UserEventHoverOptions): Promise { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) return this.withElement(element, error).hover(processHoverOptions(options)) }) } - public override async dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { + public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { // playwright doesn't enforce a single element, it selects the first found one return super.dropTo(target, processDragAndDropOptions(options)) } - public override async wheel(options: UserEventWheelOptions): Promise { + public override wheel(options: UserEventWheelOptions): Promise { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) return this.withElement(element, error).wheel(options) }) } - public override async clear(options?: UserEventClearOptions): Promise { + public override clear(options?: UserEventClearOptions): Promise { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) return this.withElement(element, error).clear(options) }) } - public override async fill(text: string, options?: UserEventFillOptions): Promise { + public override fill(text: string, options?: UserEventFillOptions): Promise { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) return this.withElement(element, error).fill(text, options) }) } - public override async screenshot(options?: LocatorScreenshotOptions): Promise { + public override screenshot(options?: LocatorScreenshotOptions): Promise { return ensureAwaited(async (error) => { const element = await this.waitForElement(options) return this.withElement(element, error).screenshot(options) From 209dcd1db47b9f4e30cf8c5911bab9062d0411fe Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 18 Feb 2026 18:27:19 +0100 Subject: [PATCH 10/17] chore: cleanup --- docs/api/browser/locators.md | 8 +++++--- packages/browser/context.d.ts | 2 +- packages/browser/src/client/tester/context.ts | 13 ++++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index 7b845bbc9534..cce06f6c0015 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -938,11 +938,13 @@ page.getByText('Hello USA').elements() // ✅ [] ### waitForElement 4.1.0 {#waitforelement} ```ts -function waitForElement(options?: SelectorOptions): Promise +function waitForElement( + options?: SelectorOptions +): Promise ``` ::: danger 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](#methods) instead. +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 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). @@ -953,7 +955,7 @@ If _multiple elements_ match the selector and `strict` is `true` (the default), It accepts options: -- `timeout: number` - How long to wait in milliseconds until a single element is found. By default, this has the same timeout as the test. +- `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: diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index b573368f8ea9..b44d11b03293 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -715,7 +715,7 @@ export interface Locator extends LocatorSelectors { * 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#waitForElement} + * @see {@link https://vitest.dev/api/browser/locators#waitforelement} */ waitForElement(options?: SelectorOptions): Promise } diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 431f3f3b5533..042dcd4f4755 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -323,13 +323,16 @@ 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) : undefined, + ...('mask' in options + ? (options.mask as Array).map(convertToSelector) + : []), + ]) + const normalizedOptions = 'mask' in options - ? { - ...options, - mask: await Promise.all((options.mask as Array).map(convertToSelector)), - } + ? { ...options, mask } : options - const element = options.element ? await convertToSelector(options.element) : undefined return ensureAwaited(error => triggerCommand( '__vitest_screenshot', From c09fd70636146a99cd5c5cb07615fba84b987809 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 19 Feb 2026 11:55:51 +0100 Subject: [PATCH 11/17] fix: provide options to `convertToSelector` --- packages/browser-webdriverio/src/locators.ts | 3 ++- packages/browser/context.d.ts | 14 ++++++++++++-- packages/browser/src/client/tester/context.ts | 6 +++--- .../src/client/tester/expect/toMatchScreenshot.ts | 6 +++--- packages/browser/src/client/tester/tester-utils.ts | 6 +++--- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index ef1178d22c7a..cdbf85a29e64 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -98,7 +98,8 @@ class WebdriverIOLocator extends Locator { } public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise { - // playwright doesn't enforce a single element, it selects the first found one + // playwright doesn't enforce a single element, it selects the first one, + // so we just follow the behavior return super.dropTo(target, processDragAndDropOptions(options)) } diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index b44d11b03293..ca20832b923c 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. @@ -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 { @@ -724,7 +734,7 @@ 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 042dcd4f4755..17dfbe30ce50 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 = await convertToSelector(element) + const selector = await convertToSelector(element, options) const { unreleased } = await triggerCommand<{ unreleased: string[] }>( '__vitest_type', [ @@ -324,9 +324,9 @@ export const page: BrowserPage = { = options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png` const [element, ...mask] = await Promise.all([ - options.element ? convertToSelector(options.element) : undefined, + options.element ? convertToSelector(options.element, options) : undefined, ...('mask' in options - ? (options.mask as Array).map(convertToSelector) + ? (options.mask as Array).map(el => convertToSelector(el, options)) : []), ]) diff --git a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts index 3391d388c561..3f42678e83bc 100644 --- a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts +++ b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts @@ -41,10 +41,10 @@ export default async function toMatchScreenshot( : `${this.currentTestName} ${counter.current}` const [element, ...mask] = await Promise.all([ - convertToSelector(actual), + convertToSelector(actual, options), ...options.screenshotOptions && 'mask' in options.screenshotOptions ? (options.screenshotOptions.mask as Array) - .map(convertToSelector) + .map(m => convertToSelector(m, options)) : [], ]) @@ -58,7 +58,7 @@ export default async function toMatchScreenshot( }, } // TS believes `mask` to still be defined as `ReadonlyArray` - : options as any + : options ) const result = await getBrowserState().commands.triggerCommand( diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 4ca844f3afef..6e25cc389109 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, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' +import type { Locator, SelectorOptions, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' import type { BrowserRPC } from '../client' import { getBrowserState, getWorkerState } from '../utils' @@ -207,7 +207,7 @@ export function escapeForTextSelector(text: string | RegExp, exact: boolean): st const provider = getBrowserState().provider const kElementLocator = Symbol.for('$$vitest:locator-resolved') -export async function convertToSelector(elementOrLocator: Element | Locator): Promise { +export async function convertToSelector(elementOrLocator: Element | Locator, options?: SelectorOptions): Promise { if (!elementOrLocator) { throw new Error('Expected element or locator to be defined.') } @@ -218,7 +218,7 @@ export async function convertToSelector(elementOrLocator: Element | Locator): Pr if (provider === 'playwright' || kElementLocator in elementOrLocator) { return elementOrLocator.selector } - const element = await elementOrLocator.waitForElement() + const element = await elementOrLocator.waitForElement(options) return convertElementToCssSelector(element) } throw new Error('Expected element or locator to be an instance of Element or Locator.') From 9915f3e16bf582ccc14ef4c10f8c1e68d6e43ae6 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 19 Feb 2026 12:29:09 +0100 Subject: [PATCH 12/17] chore: fix types --- packages/browser/context.d.ts | 2 +- .../browser/src/client/tester/expect/toMatchScreenshot.ts | 2 +- packages/browser/src/node/commands/screenshotMatcher/utils.ts | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index ca20832b923c..f93d81fc70e5 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -160,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. diff --git a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts index 3f42678e83bc..46980d7e4155 100644 --- a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts +++ b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts @@ -58,7 +58,7 @@ export default async function toMatchScreenshot( }, } // TS believes `mask` to still be defined as `ReadonlyArray` - : options + : options as any ) const result = await getBrowserState().commands.triggerCommand( diff --git a/packages/browser/src/node/commands/screenshotMatcher/utils.ts b/packages/browser/src/node/commands/screenshotMatcher/utils.ts index f70feffc92c2..fb3645ae01a6 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, From d598c4eeaf4f86e9facd60c53528a3912e63d2df Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 19 Feb 2026 15:12:09 +0100 Subject: [PATCH 13/17] test: add `waitForElement` tests --- .../src/client/tester/locators/index.ts | 3 +- test/browser/test/waitForElement.test.ts | 108 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 test/browser/test/waitForElement.test.ts diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 12a76815da90..dec70a13480c 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -47,7 +47,8 @@ const now = Date.now const waitForIntervals = [0, 20, 50, 100, 100, 500] function sleep(ms: number): Promise { - return new Promise(resolve => getSafeTimers().setTimeout(resolve, ms)) + const { setTimeout } = getSafeTimers() + return new Promise(resolve => setTimeout(resolve, ms)) } // we prefer using playwright locators because they are more powerful and support Shadow DOM diff --git a/test/browser/test/waitForElement.test.ts b/test/browser/test/waitForElement.test.ts new file mode 100644 index 000000000000..06445c471116 --- /dev/null +++ b/test/browser/test/waitForElement.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { page } from 'vitest/browser' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('locator.waitForElement can find the element if it exists', async () => { + const button = createButton() + + const element = await page.getByRole('button').waitForElement() + expect(element).toBeInTheDocument() + expect(button).toBe(element) +}) + +test('locator.waitForElement can find the element if it appears', async () => { + let button: HTMLButtonElement + + setTimeout(() => { + button = createButton() + }, 50) + + const element = await page.getByRole('button').waitForElement() + expect(element).toBeInTheDocument() + expect(button).toBe(element) +}) + +test('locator.waitForElement fails if it cannot find the element', async () => { + const locator = page.getByRole('button') + const elementsSpy = vi.spyOn(locator, 'elements') + await expect(() => { + return locator.waitForElement({ timeout: 100 }) + }).rejects.toThrow('Cannot find element with locator: getByRole(\'button\')') + // Immidiate, 0 (next tick), 20, 50, 100 + expect(elementsSpy).toHaveBeenCalledTimes(5) +}) + +test('locator.waitForElement fails if there are multiple elements by default', async () => { + createButton() + createButton() + + await expect( + () => page.getByRole('button').waitForElement(), + ).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.waitForElement fails if there are multiple elements if strict mode is specified', async () => { + createButton() + createButton() + + await expect( + () => page.getByRole('button').waitForElement({ 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.waitForElement fails if multiple elements appear later with strict mode', async () => { + setTimeout(() => { + createButton() + createButton() + }, 50) + + await expect( + () => page.getByRole('button').waitForElement(), + ).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.waitForElement returns the first button if strict is disabled', async () => { + const button = createButton() + createButton() + + const element = await page.getByRole('button').waitForElement({ strict: false }) + expect(element).toBeInTheDocument() + expect(button).toBe(element) +}) + +test('locator.waitForElement 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').waitForElement({ strict: false }) + expect(element).toBeInTheDocument() + expect(button).toBe(element) +}) + +function createButton() { + const button = document.createElement('button') + document.body.append(button) + return button +} From fbdfe9fc9eb28ddb1f7360e5e6acc2a96d6c21e0 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 19 Feb 2026 15:13:51 +0100 Subject: [PATCH 14/17] docs: clarify --- docs/api/browser/locators.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index cce06f6c0015..90dadaf4ce20 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -944,7 +944,7 @@ function waitForElement( ``` ::: danger 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 other [builtin methods](#methods) instead. +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). From 46f8f6dad65d0c934c6b1bf8d05abd1cd48fe96f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 19 Feb 2026 15:47:31 +0100 Subject: [PATCH 15/17] chore: make check more stable on CI --- test/browser/test/waitForElement.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/browser/test/waitForElement.test.ts b/test/browser/test/waitForElement.test.ts index 06445c471116..94a90eadf87e 100644 --- a/test/browser/test/waitForElement.test.ts +++ b/test/browser/test/waitForElement.test.ts @@ -31,8 +31,10 @@ test('locator.waitForElement fails if it cannot find the element', async () => { await expect(() => { return locator.waitForElement({ timeout: 100 }) }).rejects.toThrow('Cannot find element with locator: getByRole(\'button\')') + // Normally it would be 5: // Immidiate, 0 (next tick), 20, 50, 100 - expect(elementsSpy).toHaveBeenCalledTimes(5) + // But on CI it can be less because resources are limited + expect(elementsSpy.mock.calls).toBeGreaterThanOrEqual(3) }) test('locator.waitForElement fails if there are multiple elements by default', async () => { From 910fea7d7636cf35698f35864c3fc010e267f243 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 19 Feb 2026 16:03:47 +0100 Subject: [PATCH 16/17] chore: hm --- test/browser/test/waitForElement.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/browser/test/waitForElement.test.ts b/test/browser/test/waitForElement.test.ts index 94a90eadf87e..dcb9f50cf720 100644 --- a/test/browser/test/waitForElement.test.ts +++ b/test/browser/test/waitForElement.test.ts @@ -34,7 +34,7 @@ test('locator.waitForElement fails if it cannot find the element', async () => { // 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).toBeGreaterThanOrEqual(3) + expect(elementsSpy.mock.calls.length).toBeGreaterThanOrEqual(3) }) test('locator.waitForElement fails if there are multiple elements by default', async () => { From a855e9bdc83e4510c085f1ee98339d2c3d87fb95 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 20 Feb 2026 11:36:03 +0100 Subject: [PATCH 17/17] refactor: rename to `findElement` --- docs/api/browser/locators.md | 18 +++++------ packages/browser-preview/src/locators.ts | 20 ++++++------ packages/browser-webdriverio/src/locators.ts | 18 +++++------ packages/browser/context.d.ts | 11 ++++--- .../src/client/tester/locators/index.ts | 2 +- .../browser/src/client/tester/tester-utils.ts | 2 +- ...ForElement.test.ts => findElement.test.ts} | 32 +++++++++---------- 7 files changed, 52 insertions(+), 51 deletions(-) rename test/browser/test/{waitForElement.test.ts => findElement.test.ts} (63%) diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index 90dadaf4ce20..aea66be97398 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -935,10 +935,10 @@ page.getByText('Hello').elements() // ✅ [HTMLElement, HTMLElement] page.getByText('Hello USA').elements() // ✅ [] ``` -### waitForElement 4.1.0 {#waitforelement} +### findElement 4.1.0 {#findelement} ```ts -function waitForElement( +function findElement( options?: SelectorOptions ): Promise ``` @@ -969,27 +969,27 @@ Consider the following DOM structure: These locators will resolve successfully: ```ts -await page.getByText('Hello World').waitForElement() // ✅ HTMLDivElement -await page.getByText('World').waitForElement() // ✅ HTMLSpanElement -await page.getByText('Hello Germany').waitForElement() // ✅ HTMLDivElement +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').waitForElement() // ❌ -await page.getByText(/^Hello/).waitForElement() // ❌ +await page.getByText('Hello').findElement() // ❌ +await page.getByText(/^Hello/).findElement() // ❌ // no matching element before timeout -await page.getByText('Hello USA').waitForElement() // ❌ +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').waitForElement({ strict: false }) // ✅ HTMLDivElement +await page.getByText('Hello').findElement({ strict: false }) // ✅ HTMLDivElement ``` ### all diff --git a/packages/browser-preview/src/locators.ts b/packages/browser-preview/src/locators.ts index a5fecb028c90..ea1f3859ce2a 100644 --- a/packages/browser-preview/src/locators.ts +++ b/packages/browser-preview/src/locators.ts @@ -36,42 +36,42 @@ class PreviewLocator extends Locator { } async click(options?: UserEventClickOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.click(element) } async dblClick(options?: UserEventClickOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.dblClick(element) } async tripleClick(options?: UserEventClickOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.tripleClick(element) } async hover(options?: UserEventHoverOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.hover(element) } async unhover(options?: UserEventHoverOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.unhover(element) } async fill(text: string, options?: UserEventFillOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.fill(element, text) } async upload(file: string | string[] | File | File[], options?: UserEventUploadOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.upload(element, file) } async wheel(options: UserEventWheelOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.wheel(element, options) } @@ -79,12 +79,12 @@ class PreviewLocator extends Locator { options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[], settings?: UserEventSelectOptions, ): Promise { - const element = await this.waitForElement(settings) + const element = await this.findElement(settings) return userEvent.selectOptions(element, options) } async clear(options?: UserEventClearOptions): Promise { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return userEvent.clear(element) } diff --git a/packages/browser-webdriverio/src/locators.ts b/packages/browser-webdriverio/src/locators.ts index cdbf85a29e64..e7210d1b9b71 100644 --- a/packages/browser-webdriverio/src/locators.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -56,21 +56,21 @@ class WebdriverIOLocator extends Locator { public override click(options?: UserEventClickOptions): Promise { return ensureAwaited(async (error) => { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return this.withElement(element, error).click(processClickOptions(options)) }) } public override dblClick(options?: UserEventClickOptions): Promise { return ensureAwaited(async (error) => { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return this.withElement(element, error).dblClick(processClickOptions(options)) }) } public override tripleClick(options?: UserEventClickOptions): Promise { return ensureAwaited(async (error) => { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return this.withElement(element, error).tripleClick(processClickOptions(options)) }) } @@ -80,7 +80,7 @@ class WebdriverIOLocator extends Locator { options?: UserEventSelectOptions, ): Promise { return ensureAwaited(async (error) => { - const element = await this.waitForElement(options) + const element = await this.findElement(options) const values = getWebdriverioSelectOptions(element, value) return triggerCommandWithTrace({ name: '__vitest_selectOptions', @@ -92,7 +92,7 @@ class WebdriverIOLocator extends Locator { public override hover(options?: UserEventHoverOptions): Promise { return ensureAwaited(async (error) => { - const element = await this.waitForElement(options) + const element = await this.findElement(options) return this.withElement(element, error).hover(processHoverOptions(options)) }) } @@ -105,28 +105,28 @@ class WebdriverIOLocator extends Locator { public override wheel(options: UserEventWheelOptions): Promise { return ensureAwaited(async (error) => { - const element = await this.waitForElement(options) + 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.waitForElement(options) + 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.waitForElement(options) + 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.waitForElement(options) + const element = await this.findElement(options) return this.withElement(element, error).screenshot(options) }) } diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index f93d81fc70e5..094f61e30c24 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -716,18 +716,19 @@ export interface Locator extends LocatorSelectors { */ filter(options: LocatorOptions): Locator /** - * Returns the HTML element matching the locator. - * This method will wait until only a single element appears in the DOM, but - * the strictness can be configured with options. + * 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#waitforelement} + * @see {@link https://vitest.dev/api/browser/locators#findelement} */ - waitForElement(options?: SelectorOptions): Promise + findElement(options?: SelectorOptions): Promise } export interface UserEventTabOptions { diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index dec70a13480c..1fd0cd9ad732 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -314,7 +314,7 @@ export abstract class Locator { return this.selector } - public async waitForElement(options_: SelectorOptions = {}): Promise { + public async findElement(options_: SelectorOptions = {}): Promise { const options = processTimeoutOptions(options_) const timeout = options?.timeout const strict = options?.strict ?? true diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 6e25cc389109..1176a2c04f04 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -218,7 +218,7 @@ export async function convertToSelector(elementOrLocator: Element | Locator, opt if (provider === 'playwright' || kElementLocator in elementOrLocator) { return elementOrLocator.selector } - const element = await elementOrLocator.waitForElement(options) + 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/test/browser/test/waitForElement.test.ts b/test/browser/test/findElement.test.ts similarity index 63% rename from test/browser/test/waitForElement.test.ts rename to test/browser/test/findElement.test.ts index dcb9f50cf720..1a211f9d267e 100644 --- a/test/browser/test/waitForElement.test.ts +++ b/test/browser/test/findElement.test.ts @@ -5,31 +5,31 @@ beforeEach(() => { document.body.innerHTML = '' }) -test('locator.waitForElement can find the element if it exists', async () => { +test('locator.findElement can find the element if it exists', async () => { const button = createButton() - const element = await page.getByRole('button').waitForElement() + const element = await page.getByRole('button').findElement() expect(element).toBeInTheDocument() expect(button).toBe(element) }) -test('locator.waitForElement can find the element if it appears', async () => { +test('locator.findElement can find the element if it appears', async () => { let button: HTMLButtonElement setTimeout(() => { button = createButton() }, 50) - const element = await page.getByRole('button').waitForElement() + const element = await page.getByRole('button').findElement() expect(element).toBeInTheDocument() expect(button).toBe(element) }) -test('locator.waitForElement fails if it cannot find the element', async () => { +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.waitForElement({ timeout: 100 }) + 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 @@ -37,12 +37,12 @@ test('locator.waitForElement fails if it cannot find the element', async () => { expect(elementsSpy.mock.calls.length).toBeGreaterThanOrEqual(3) }) -test('locator.waitForElement fails if there are multiple elements by default', async () => { +test('locator.findElement fails if there are multiple elements by default', async () => { createButton() createButton() await expect( - () => page.getByRole('button').waitForElement(), + () => page.getByRole('button').findElement(), ).rejects.toThrowErrorMatchingInlineSnapshot(` [Error: strict mode violation: getByRole('button') resolved to 2 elements: 1) aka getByRole('button').first() @@ -51,12 +51,12 @@ test('locator.waitForElement fails if there are multiple elements by default', a `) }) -test('locator.waitForElement fails if there are multiple elements if strict mode is specified', async () => { +test('locator.findElement fails if there are multiple elements if strict mode is specified', async () => { createButton() createButton() await expect( - () => page.getByRole('button').waitForElement({ strict: true }), + () => page.getByRole('button').findElement({ strict: true }), ).rejects.toThrowErrorMatchingInlineSnapshot(` [Error: strict mode violation: getByRole('button') resolved to 2 elements: 1) aka getByRole('button').first() @@ -65,14 +65,14 @@ test('locator.waitForElement fails if there are multiple elements if strict mode `) }) -test('locator.waitForElement fails if multiple elements appear later with strict mode', async () => { +test('locator.findElement fails if multiple elements appear later with strict mode', async () => { setTimeout(() => { createButton() createButton() }, 50) await expect( - () => page.getByRole('button').waitForElement(), + () => page.getByRole('button').findElement(), ).rejects.toThrowErrorMatchingInlineSnapshot(` [Error: strict mode violation: getByRole('button') resolved to 2 elements: 1) aka getByRole('button').first() @@ -81,16 +81,16 @@ test('locator.waitForElement fails if multiple elements appear later with strict `) }) -test('locator.waitForElement returns the first button if strict is disabled', async () => { +test('locator.findElement returns the first button if strict is disabled', async () => { const button = createButton() createButton() - const element = await page.getByRole('button').waitForElement({ strict: false }) + const element = await page.getByRole('button').findElement({ strict: false }) expect(element).toBeInTheDocument() expect(button).toBe(element) }) -test('locator.waitForElement returns the first button if strict is disabled after element appears', async () => { +test('locator.findElement returns the first button if strict is disabled after element appears', async () => { let button: HTMLButtonElement setTimeout(() => { @@ -98,7 +98,7 @@ test('locator.waitForElement returns the first button if strict is disabled afte createButton() }, 50) - const element = await page.getByRole('button').waitForElement({ strict: false }) + const element = await page.getByRole('button').findElement({ strict: false }) expect(element).toBeInTheDocument() expect(button).toBe(element) })