[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): add `findElement` and enable strict mode in webdriverio and preview (#9677)

authored by

Vladimir and committed by
GitHub
(Feb 20, 2026, 12:04 PM +0100) c3f37721 b7902bcb

+616 -80
+43 -3
packages/browser/context.d.ts
··· 21 21 // methods are defined by the provider type augmentation 22 22 } 23 23 24 - export interface ScreenshotOptions { 24 + export interface ScreenshotOptions extends SelectorOptions { 25 + /** 26 + * The HTML element to screeshot. 27 + */ 25 28 element?: Element | Locator 26 29 /** 27 30 * Path relative to the current test file. ··· 157 160 comparatorOptions?: ScreenshotComparatorRegistry[ComparatorName] 158 161 screenshotOptions?: Omit< 159 162 ScreenshotOptions, 160 - 'element' | 'base64' | 'path' | 'save' | 'type' 163 + 'element' | 'base64' | 'path' | 'save' | 'type' | 'strict' | 'timeout' 161 164 > 162 165 /** 163 166 * Time to wait until a stable screenshot is found. ··· 168 171 * @default 5000 169 172 */ 170 173 timeout?: number 174 + /** 175 + * Allow only a single element with the same locator. 176 + * 177 + * If Vitest finds multiple elements, it will throw an error immediately without retrying. 178 + * @default true 179 + */ 180 + strict?: boolean 171 181 } 172 182 173 183 export interface UserEvent { ··· 522 532 523 533 export interface FrameLocator extends LocatorSelectors {} 524 534 535 + export interface SelectorOptions { 536 + /** 537 + * How long to wait until a single element is found. By default, this has the same timeout as the test. 538 + * 539 + * Vitest will try to find the element in ever increasing intervals: 0, 20, 50, 100, 100, 500. 540 + */ 541 + timeout?: number 542 + /** 543 + * Allow only a single element with the same locator. 544 + * 545 + * If Vitest finds multiple elements, it will throw an error immediately without retrying. 546 + * @default true 547 + */ 548 + strict?: boolean 549 + } 550 + 525 551 export interface Locator extends LocatorSelectors { 526 552 /** 527 553 * Selector string that will be used to locate the element by the browser provider. ··· 689 715 * @see {@link https://vitest.dev/api/browser/locators#filter} 690 716 */ 691 717 filter(options: LocatorOptions): Locator 718 + /** 719 + * This method returns an element matching the locator. 720 + * Unlike [`.element()`](https://vitest.dev/api/browser/locators#element), 721 + * this method will wait and retry until a matching element appears in the DOM, 722 + * using increasing intervals (0, 20, 50, 100, 100, 500ms). 723 + * 724 + * **WARNING:** 725 + * 726 + * This is an escape hatch for library authors and 3d-party APIs that do not support locators directly. 727 + * If you are interacting with the element, use builtin methods instead. 728 + * @since 4.1.0 729 + * @see {@link https://vitest.dev/api/browser/locators#findelement} 730 + */ 731 + findElement(options?: SelectorOptions): Promise<HTMLElement | SVGElement> 692 732 } 693 733 694 734 export interface UserEventTabOptions { 695 735 shift?: boolean 696 736 } 697 737 698 - export interface UserEventTypeOptions { 738 + export interface UserEventTypeOptions extends SelectorOptions { 699 739 skipClick?: boolean 700 740 skipAutoClose?: boolean 701 741 }
+57
docs/api/browser/locators.md
··· 935 935 page.getByText('Hello USA').elements() // ✅ [] 936 936 ``` 937 937 938 + ### findElement <Version>4.1.0</Version> {#findelement} 939 + 940 + ```ts 941 + function findElement( 942 + options?: SelectorOptions 943 + ): Promise<HTMLElement | SVGElement> 944 + ``` 945 + 946 + ::: danger WARNING 947 + 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. 948 + ::: 949 + 950 + 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). 951 + 952 + If _no element_ is found before the timeout, an error is thrown. By default, the timeout matches the test timeout. 953 + 954 + 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. 955 + 956 + It accepts options: 957 + 958 + - `timeout: number` - How long to wait in milliseconds until at least one element is found. By default, this shares timeout with the test. 959 + - `strict: boolean` - When `true` (default), throws an error if multiple elements match the locator. When `false`, returns the first matching element. 960 + 961 + Consider the following DOM structure: 962 + 963 + ```html 964 + <div>Hello <span>World</span></div> 965 + <div>Hello Germany</div> 966 + <div>Hello</div> 967 + ``` 968 + 969 + These locators will resolve successfully: 970 + 971 + ```ts 972 + await page.getByText('Hello World').findElement() // ✅ HTMLDivElement 973 + await page.getByText('World').findElement() // ✅ HTMLSpanElement 974 + await page.getByText('Hello Germany').findElement() // ✅ HTMLDivElement 975 + ``` 976 + 977 + These locators will throw an error: 978 + 979 + ```ts 980 + // multiple elements match, strict mode rejects 981 + await page.getByText('Hello').findElement() // ❌ 982 + await page.getByText(/^Hello/).findElement() // ❌ 983 + 984 + // no matching element before timeout 985 + await page.getByText('Hello USA').findElement() // ❌ 986 + ``` 987 + 988 + Using `strict: false` to allow multiple matches: 989 + 990 + ```ts 991 + // returns the first matching element instead of throwing 992 + await page.getByText('Hello').findElement({ strict: false }) // ✅ HTMLDivElement 993 + ``` 994 + 938 995 ### all 939 996 940 997 ```ts
+44 -18
packages/browser-preview/src/locators.ts
··· 1 + import type { 2 + UserEventClearOptions, 3 + UserEventClickOptions, 4 + UserEventFillOptions, 5 + UserEventHoverOptions, 6 + UserEventSelectOptions, 7 + UserEventUploadOptions, 8 + UserEventWheelOptions, 9 + } from 'vitest/browser' 1 10 import { 2 11 convertElementToCssSelector, 3 12 getByAltTextSelector, ··· 26 35 return selectors.join(', ') 27 36 } 28 37 29 - click(): Promise<void> { 30 - return userEvent.click(this.element()) 38 + async click(options?: UserEventClickOptions): Promise<void> { 39 + const element = await this.findElement(options) 40 + return userEvent.click(element) 31 41 } 32 42 33 - dblClick(): Promise<void> { 34 - return userEvent.dblClick(this.element()) 43 + async dblClick(options?: UserEventClickOptions): Promise<void> { 44 + const element = await this.findElement(options) 45 + return userEvent.dblClick(element) 35 46 } 36 47 37 - tripleClick(): Promise<void> { 38 - return userEvent.tripleClick(this.element()) 48 + async tripleClick(options?: UserEventClickOptions): Promise<void> { 49 + const element = await this.findElement(options) 50 + return userEvent.tripleClick(element) 39 51 } 40 52 41 - hover(): Promise<void> { 42 - return userEvent.hover(this.element()) 53 + async hover(options?: UserEventHoverOptions): Promise<void> { 54 + const element = await this.findElement(options) 55 + return userEvent.hover(element) 43 56 } 44 57 45 - unhover(): Promise<void> { 46 - return userEvent.unhover(this.element()) 58 + async unhover(options?: UserEventHoverOptions): Promise<void> { 59 + const element = await this.findElement(options) 60 + return userEvent.unhover(element) 47 61 } 48 62 49 - async fill(text: string): Promise<void> { 50 - return userEvent.fill(this.element(), text) 63 + async fill(text: string, options?: UserEventFillOptions): Promise<void> { 64 + const element = await this.findElement(options) 65 + return userEvent.fill(element, text) 51 66 } 52 67 53 - async upload(file: string | string[] | File | File[]): Promise<void> { 54 - return userEvent.upload(this.element(), file) 68 + async upload(file: string | string[] | File | File[], options?: UserEventUploadOptions): Promise<void> { 69 + const element = await this.findElement(options) 70 + return userEvent.upload(element, file) 55 71 } 56 72 57 - selectOptions(options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise<void> { 58 - return userEvent.selectOptions(this.element(), options) 73 + async wheel(options: UserEventWheelOptions): Promise<void> { 74 + const element = await this.findElement(options) 75 + return userEvent.wheel(element, options) 59 76 } 60 77 61 - clear(): Promise<void> { 62 - return userEvent.clear(this.element()) 78 + async selectOptions( 79 + options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[], 80 + settings?: UserEventSelectOptions, 81 + ): Promise<void> { 82 + const element = await this.findElement(settings) 83 + return userEvent.selectOptions(element, options) 84 + } 85 + 86 + async clear(options?: UserEventClearOptions): Promise<void> { 87 + const element = await this.findElement(options) 88 + return userEvent.clear(element) 63 89 } 64 90 65 91 protected locator(selector: string) {
+14
packages/browser-preview/src/preview.ts
··· 1 + import type { SelectorOptions } from 'vitest/browser' 1 2 import type { BrowserProvider, BrowserProviderOption, TestProject } from 'vitest/node' 2 3 import { nextTick } from 'node:process' 3 4 import { defineBrowserProvider } from '@vitest/browser' ··· 59 60 } 60 61 61 62 async close(): Promise<void> {} 63 + } 64 + 65 + declare module 'vitest/browser' { 66 + export interface UserEventClickOptions extends SelectorOptions {} 67 + export interface UserEventHoverOptions extends SelectorOptions {} 68 + export interface UserEventFillOptions extends SelectorOptions {} 69 + export interface UserEventSelectOptions extends SelectorOptions {} 70 + export interface UserEventClearOptions extends SelectorOptions {} 71 + export interface UserEventDoubleClickOptions extends SelectorOptions {} 72 + export interface UserEventTripleClickOptions extends SelectorOptions {} 73 + export interface UserEventUploadOptions extends SelectorOptions {} 74 + export interface UserEventWheelBaseOptions extends SelectorOptions {} 75 + export interface LocatorScreenshotOptions extends SelectorOptions {} 62 76 }
+98 -6
packages/browser-webdriverio/src/locators.ts
··· 1 1 import type { 2 + LocatorScreenshotOptions, 3 + UserEventClearOptions, 2 4 UserEventClickOptions, 3 5 UserEventDragAndDropOptions, 6 + UserEventFillOptions, 4 7 UserEventHoverOptions, 5 8 UserEventSelectOptions, 9 + UserEventWheelOptions, 6 10 } from 'vitest/browser' 7 11 import { 8 12 convertElementToCssSelector, 13 + ensureAwaited, 9 14 getByAltTextSelector, 10 15 getByLabelSelector, 11 16 getByPlaceholderSelector, ··· 16 21 getIframeScale, 17 22 Locator, 18 23 selectorEngine, 24 + triggerCommandWithTrace, 19 25 } from '@vitest/browser/locators' 20 26 import { page, server, utils } from 'vitest/browser' 21 27 import { __INTERNAL } from 'vitest/internal/browser' ··· 23 29 class WebdriverIOLocator extends Locator { 24 30 constructor(protected _pwSelector: string, protected _container?: Element) { 25 31 super() 32 + } 33 + 34 + // This exists to avoid calling `this.elements` in `this.selector`'s getter in interactive actions 35 + private withElement(element: Element, error: Error | undefined) { 36 + const pwSelector = selectorEngine.generateSelectorSimple(element) 37 + const cssSelector = convertElementToCssSelector(element) 38 + return new ElementWebdriverIOLocator(cssSelector, error, pwSelector, element) 26 39 } 27 40 28 41 override get selector(): string { ··· 42 55 } 43 56 44 57 public override click(options?: UserEventClickOptions): Promise<void> { 45 - return super.click(processClickOptions(options)) 58 + return ensureAwaited(async (error) => { 59 + const element = await this.findElement(options) 60 + return this.withElement(element, error).click(processClickOptions(options)) 61 + }) 46 62 } 47 63 48 64 public override dblClick(options?: UserEventClickOptions): Promise<void> { 49 - return super.dblClick(processClickOptions(options)) 65 + return ensureAwaited(async (error) => { 66 + const element = await this.findElement(options) 67 + return this.withElement(element, error).dblClick(processClickOptions(options)) 68 + }) 50 69 } 51 70 52 71 public override tripleClick(options?: UserEventClickOptions): Promise<void> { 53 - return super.tripleClick(processClickOptions(options)) 72 + return ensureAwaited(async (error) => { 73 + const element = await this.findElement(options) 74 + return this.withElement(element, error).tripleClick(processClickOptions(options)) 75 + }) 54 76 } 55 77 56 78 public selectOptions( 57 79 value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], 58 80 options?: UserEventSelectOptions, 59 81 ): Promise<void> { 60 - const values = getWebdriverioSelectOptions(this.element(), value) 61 - return this.triggerCommand('__vitest_selectOptions', this.selector, values, options) 82 + return ensureAwaited(async (error) => { 83 + const element = await this.findElement(options) 84 + const values = getWebdriverioSelectOptions(element, value) 85 + return triggerCommandWithTrace<void>({ 86 + name: '__vitest_selectOptions', 87 + arguments: [convertElementToCssSelector(element), values, options], 88 + errorSource: error, 89 + }) 90 + }) 62 91 } 63 92 64 93 public override hover(options?: UserEventHoverOptions): Promise<void> { 65 - return super.hover(processHoverOptions(options)) 94 + return ensureAwaited(async (error) => { 95 + const element = await this.findElement(options) 96 + return this.withElement(element, error).hover(processHoverOptions(options)) 97 + }) 66 98 } 67 99 68 100 public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise<void> { 101 + // playwright doesn't enforce a single element, it selects the first one, 102 + // so we just follow the behavior 69 103 return super.dropTo(target, processDragAndDropOptions(options)) 70 104 } 105 + 106 + public override wheel(options: UserEventWheelOptions): Promise<void> { 107 + return ensureAwaited(async (error) => { 108 + const element = await this.findElement(options) 109 + return this.withElement(element, error).wheel(options) 110 + }) 111 + } 112 + 113 + public override clear(options?: UserEventClearOptions): Promise<void> { 114 + return ensureAwaited(async (error) => { 115 + const element = await this.findElement(options) 116 + return this.withElement(element, error).clear(options) 117 + }) 118 + } 119 + 120 + public override fill(text: string, options?: UserEventFillOptions): Promise<void> { 121 + return ensureAwaited(async (error) => { 122 + const element = await this.findElement(options) 123 + return this.withElement(element, error).fill(text, options) 124 + }) 125 + } 126 + 127 + public override screenshot(options?: LocatorScreenshotOptions): Promise<any> { 128 + return ensureAwaited(async (error) => { 129 + const element = await this.findElement(options) 130 + return this.withElement(element, error).screenshot(options) 131 + }) 132 + } 133 + 134 + // playwright doesn't enforce a single element in upload 135 + // public override async upload(): Promise<void> 71 136 72 137 protected locator(selector: string) { 73 138 return new WebdriverIOLocator(`${this._pwSelector} >> ${selector}`, this._container) ··· 75 140 76 141 protected elementLocator(element: Element) { 77 142 return new WebdriverIOLocator(selectorEngine.generateSelectorSimple(element), element) 143 + } 144 + } 145 + 146 + const kElementLocator = Symbol.for('$$vitest:locator-resolved') 147 + 148 + class ElementWebdriverIOLocator extends Locator { 149 + public [kElementLocator] = true 150 + 151 + constructor( 152 + private _cssSelector: string, 153 + protected _errorSource: Error | undefined, 154 + protected _pwSelector: string, 155 + protected _container: Element, 156 + ) { 157 + super() 158 + } 159 + 160 + override get selector() { 161 + return this._cssSelector 162 + } 163 + 164 + protected locator(_selector: string): Locator { 165 + throw new Error(`should not be called`) 166 + } 167 + 168 + protected elementLocator(_element: Element): Locator { 169 + throw new Error(`should not be called`) 78 170 } 79 171 } 80 172
+10 -3
packages/browser-webdriverio/src/webdriverio.ts
··· 3 3 import type { 4 4 ScreenshotComparatorRegistry, 5 5 ScreenshotMatcherOptions, 6 + SelectorOptions, 6 7 } from 'vitest/browser' 7 8 import type { 8 9 BrowserCommand, ··· 288 289 } 289 290 290 291 declare module 'vitest/browser' { 291 - export interface UserEventClickOptions extends Partial<ClickOptions> {} 292 - export interface UserEventHoverOptions extends MoveToOptions {} 293 - 292 + export interface UserEventClickOptions extends Partial<ClickOptions>, SelectorOptions {} 293 + export interface UserEventHoverOptions extends MoveToOptions, SelectorOptions {} 294 294 export interface UserEventDragAndDropOptions extends DragAndDropOptions { 295 295 sourceX?: number 296 296 sourceY?: number 297 297 targetX?: number 298 298 targetY?: number 299 299 } 300 + export interface UserEventFillOptions extends SelectorOptions {} 301 + export interface UserEventSelectOptions extends SelectorOptions {} 302 + export interface UserEventClearOptions extends SelectorOptions {} 303 + export interface UserEventDoubleClickOptions extends SelectorOptions {} 304 + export interface UserEventTripleClickOptions extends SelectorOptions {} 305 + export interface UserEventWheelBaseOptions extends SelectorOptions {} 306 + export interface LocatorScreenshotOptions extends SelectorOptions {} 300 307 } 301 308 302 309 declare module 'vitest/node' {
+110
test/browser/test/findElement.test.ts
··· 1 + import { beforeEach, expect, test, vi } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + beforeEach(() => { 5 + document.body.innerHTML = '' 6 + }) 7 + 8 + test('locator.findElement can find the element if it exists', async () => { 9 + const button = createButton() 10 + 11 + const element = await page.getByRole('button').findElement() 12 + expect(element).toBeInTheDocument() 13 + expect(button).toBe(element) 14 + }) 15 + 16 + test('locator.findElement can find the element if it appears', async () => { 17 + let button: HTMLButtonElement 18 + 19 + setTimeout(() => { 20 + button = createButton() 21 + }, 50) 22 + 23 + const element = await page.getByRole('button').findElement() 24 + expect(element).toBeInTheDocument() 25 + expect(button).toBe(element) 26 + }) 27 + 28 + test('locator.findElement fails if it cannot find the element', async () => { 29 + const locator = page.getByRole('button') 30 + const elementsSpy = vi.spyOn(locator, 'elements') 31 + await expect(() => { 32 + return locator.findElement({ timeout: 100 }) 33 + }).rejects.toThrow('Cannot find element with locator: getByRole(\'button\')') 34 + // Normally it would be 5: 35 + // Immidiate, 0 (next tick), 20, 50, 100 36 + // But on CI it can be less because resources are limited 37 + expect(elementsSpy.mock.calls.length).toBeGreaterThanOrEqual(3) 38 + }) 39 + 40 + test('locator.findElement fails if there are multiple elements by default', async () => { 41 + createButton() 42 + createButton() 43 + 44 + await expect( 45 + () => page.getByRole('button').findElement(), 46 + ).rejects.toThrowErrorMatchingInlineSnapshot(` 47 + [Error: strict mode violation: getByRole('button') resolved to 2 elements: 48 + 1) <button></button> aka getByRole('button').first() 49 + 2) <button></button> aka getByRole('button').nth(1) 50 + ] 51 + `) 52 + }) 53 + 54 + test('locator.findElement fails if there are multiple elements if strict mode is specified', async () => { 55 + createButton() 56 + createButton() 57 + 58 + await expect( 59 + () => page.getByRole('button').findElement({ strict: true }), 60 + ).rejects.toThrowErrorMatchingInlineSnapshot(` 61 + [Error: strict mode violation: getByRole('button') resolved to 2 elements: 62 + 1) <button></button> aka getByRole('button').first() 63 + 2) <button></button> aka getByRole('button').nth(1) 64 + ] 65 + `) 66 + }) 67 + 68 + test('locator.findElement fails if multiple elements appear later with strict mode', async () => { 69 + setTimeout(() => { 70 + createButton() 71 + createButton() 72 + }, 50) 73 + 74 + await expect( 75 + () => page.getByRole('button').findElement(), 76 + ).rejects.toThrowErrorMatchingInlineSnapshot(` 77 + [Error: strict mode violation: getByRole('button') resolved to 2 elements: 78 + 1) <button></button> aka getByRole('button').first() 79 + 2) <button></button> aka getByRole('button').nth(1) 80 + ] 81 + `) 82 + }) 83 + 84 + test('locator.findElement returns the first button if strict is disabled', async () => { 85 + const button = createButton() 86 + createButton() 87 + 88 + const element = await page.getByRole('button').findElement({ strict: false }) 89 + expect(element).toBeInTheDocument() 90 + expect(button).toBe(element) 91 + }) 92 + 93 + test('locator.findElement returns the first button if strict is disabled after element appears', async () => { 94 + let button: HTMLButtonElement 95 + 96 + setTimeout(() => { 97 + button = createButton() 98 + createButton() 99 + }, 50) 100 + 101 + const element = await page.getByRole('button').findElement({ strict: false }) 102 + expect(element).toBeInTheDocument() 103 + expect(button).toBe(element) 104 + }) 105 + 106 + function createButton() { 107 + const button = document.createElement('button') 108 + document.body.append(button) 109 + return button 110 + }
+86 -2
test/browser/test/userEvent.test.ts
··· 1 1 import { beforeEach, describe, expect, test, vi } from 'vitest' 2 - import { userEvent as _uE, server } from 'vitest/browser' 2 + import { userEvent as _uE, page, server } from 'vitest/browser' 3 3 import '../src/button.css' 4 4 5 5 beforeEach(() => { ··· 157 157 y: expect.closeTo(150, -1), 158 158 }) 159 159 }) 160 + 161 + test('click throws an error with multiple elements', async () => { 162 + const button1 = document.createElement('button') 163 + const button2 = document.createElement('button') 164 + document.body.append(button1, button2) 165 + 166 + await expect(() => page.getByRole('button').click()).rejects.toThrow( 167 + `strict mode violation: getByRole('button') resolved to 2 elements:\n` 168 + + ` 1) <button></button> aka getByRole('button').first()\n` 169 + + ` 2) <button></button> aka getByRole('button').nth(1)`, 170 + ) 171 + }) 160 172 }) 161 173 162 174 describe('userEvent.dblClick', () => { ··· 192 204 193 205 expect(onClick).not.toHaveBeenCalled() 194 206 expect(dblClick).not.toHaveBeenCalled() 207 + }) 208 + 209 + test('double click throws an error with multiple elements', async () => { 210 + const button1 = document.createElement('button') 211 + const button2 = document.createElement('button') 212 + document.body.append(button1, button2) 213 + 214 + await expect(() => page.getByRole('button').dblClick()).rejects.toThrow( 215 + `strict mode violation: getByRole('button') resolved to 2 elements:\n` 216 + + ` 1) <button></button> aka getByRole('button').first()\n` 217 + + ` 2) <button></button> aka getByRole('button').nth(1)`, 218 + ) 195 219 }) 196 220 }) 197 221 ··· 239 263 expect(dblClick).not.toHaveBeenCalled() 240 264 expect(tripleClick).not.toHaveBeenCalled() 241 265 }) 266 + 267 + test('triple click throws an error with multiple elements', async () => { 268 + const button1 = document.createElement('button') 269 + const button2 = document.createElement('button') 270 + document.body.append(button1, button2) 271 + 272 + await expect(() => page.getByRole('button').tripleClick()).rejects.toThrow( 273 + `strict mode violation: getByRole('button') resolved to 2 elements:\n` 274 + + ` 1) <button></button> aka getByRole('button').first()\n` 275 + + ` 2) <button></button> aka getByRole('button').nth(1)`, 276 + ) 277 + }) 242 278 }) 243 279 244 280 describe('userEvent.hover, userEvent.unhover', () => { ··· 274 310 275 311 expect(pointerEntered).toBe(false) 276 312 expect(mouseEntered).toBe(false) 313 + }) 314 + 315 + test('hover throws an error with multiple elements', async () => { 316 + const button1 = document.createElement('button') 317 + const button2 = document.createElement('button') 318 + document.body.append(button1, button2) 319 + 320 + await expect(() => page.getByRole('button').hover()).rejects.toThrow( 321 + `strict mode violation: getByRole('button') resolved to 2 elements:\n` 322 + + ` 1) <button></button> aka getByRole('button').first()\n` 323 + + ` 2) <button></button> aka getByRole('button').nth(1)`, 324 + ) 277 325 }) 278 326 279 327 test.runIf(server.provider === 'playwright')('hover, unhover correctly pass options', async () => { ··· 408 456 return contentEditable 409 457 }, 410 458 ] 459 + 460 + test('type throws an error with multiple elements', async () => { 461 + const button1 = document.createElement('button') 462 + const button2 = document.createElement('button') 463 + document.body.append(button1, button2) 464 + 465 + await expect(() => userEvent.type(page.getByRole('button'), 'Hello World')).rejects.toThrow( 466 + `strict mode violation: getByRole('button') resolved to 2 elements:\n` 467 + + ` 1) <button></button> aka getByRole('button').first()\n` 468 + + ` 2) <button></button> aka getByRole('button').nth(1)`, 469 + ) 470 + }) 471 + 472 + test('fill throws an error with multiple elements', async () => { 473 + const button1 = document.createElement('button') 474 + const button2 = document.createElement('button') 475 + document.body.append(button1, button2) 476 + 477 + await expect(() => page.getByRole('button').fill('Hello World')).rejects.toThrow( 478 + `strict mode violation: getByRole('button') resolved to 2 elements:\n` 479 + + ` 1) <button></button> aka getByRole('button').first()\n` 480 + + ` 2) <button></button> aka getByRole('button').nth(1)`, 481 + ) 482 + }) 411 483 412 484 describe.each(inputLike)('userEvent.type', (getElement) => { 413 485 test('types into an input', async () => { ··· 814 886 // return { select, options: [option1, option2] } 815 887 // }, 816 888 // ], 817 - ])('selectOptions in "%s" works correctly', (_, createSelect) => { 889 + ])('selectOptions in "%s" works correctly', (name, createSelect) => { 890 + test(`${name} throws an error with multiple elements`, async () => { 891 + const button1 = document.createElement('button') 892 + const button2 = document.createElement('button') 893 + document.body.append(button1, button2) 894 + 895 + await expect(() => page.getByRole('button').selectOptions('Hello World')).rejects.toThrow( 896 + `strict mode violation: getByRole('button') resolved to 2 elements:\n` 897 + + ` 1) <button></button> aka getByRole('button').first()\n` 898 + + ` 2) <button></button> aka getByRole('button').nth(1)`, 899 + ) 900 + }) 901 + 818 902 test('can select a single primitive value', async () => { 819 903 const { select } = createSelect() 820 904
+10 -8
packages/browser/src/client/tester/context.ts
··· 102 102 // testing-library user-event 103 103 async type(element, text, options) { 104 104 return ensureAwaited(async (error) => { 105 - const selector = convertToSelector(element) 105 + const selector = await convertToSelector(element, options) 106 106 const { unreleased } = await triggerCommand<{ unreleased: string[] }>( 107 107 '__vitest_type', 108 108 [ ··· 323 323 const name 324 324 = options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png` 325 325 326 + const [element, ...mask] = await Promise.all([ 327 + options.element ? convertToSelector(options.element, options) : undefined, 328 + ...('mask' in options 329 + ? (options.mask as Array<Element | Locator>).map(el => convertToSelector(el, options)) 330 + : []), 331 + ]) 332 + 326 333 const normalizedOptions = 'mask' in options 327 - ? { 328 - ...options, 329 - mask: (options.mask as Array<Element | Locator>).map(convertToSelector), 330 - } 334 + ? { ...options, mask } 331 335 : options 332 336 333 337 return ensureAwaited(error => triggerCommand( ··· 336 340 name, 337 341 processTimeoutOptions({ 338 342 ...normalizedOptions, 339 - element: options.element 340 - ? convertToSelector(options.element) 341 - : undefined, 343 + element, 342 344 } as any /** TODO */), 343 345 ], 344 346 error,
+11 -9
packages/browser/src/client/tester/tester-utils.ts
··· 1 - import type { Locator, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' 1 + import type { Locator, SelectorOptions, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' 2 2 import type { BrowserRPC } from '../client' 3 3 import { getBrowserState, getWorkerState } from '../utils' 4 - 5 - const provider = getBrowserState().provider 6 4 7 5 /* @__NO_SIDE_EFFECTS__ */ 8 6 export function convertElementToCssSelector(element: Element): string { ··· 130 128 () => 131 129 rpc.triggerCommand<T>(sessionId, command, filepath, args).catch((err) => { 132 130 // rethrow an error to keep the stack trace in browser 133 - // const clientError = new Error(err.message) 134 131 clientError.message = err.message 135 132 clientError.name = err.name 136 133 clientError.stack = clientError.stack?.replace(clientError.message, err.message) ··· 142 139 143 140 const now = Date.now 144 141 145 - export function processTimeoutOptions<T extends { timeout?: number }>(options_?: T): T | undefined { 142 + export function processTimeoutOptions<T extends { timeout?: number }>(options_: T | undefined): T | undefined { 146 143 if ( 147 144 // if timeout is set, keep it 148 145 (options_ && options_.timeout != null) 149 - // timeout can only be set for playwright commands 150 - || provider !== 'playwright' 151 146 ) { 152 147 return options_ 153 148 } ··· 209 204 return `${JSON.stringify(text)}${exact ? 's' : 'i'}` 210 205 } 211 206 212 - export function convertToSelector(elementOrLocator: Element | Locator): string { 207 + const provider = getBrowserState().provider 208 + const kElementLocator = Symbol.for('$$vitest:locator-resolved') 209 + 210 + export async function convertToSelector(elementOrLocator: Element | Locator, options?: SelectorOptions): Promise<string> { 213 211 if (!elementOrLocator) { 214 212 throw new Error('Expected element or locator to be defined.') 215 213 } ··· 217 215 return convertElementToCssSelector(elementOrLocator) 218 216 } 219 217 if (isLocator(elementOrLocator)) { 220 - return elementOrLocator.selector 218 + if (provider === 'playwright' || kElementLocator in elementOrLocator) { 219 + return elementOrLocator.selector 220 + } 221 + const element = await elementOrLocator.findElement(options) 222 + return convertElementToCssSelector(element) 221 223 } 222 224 throw new Error('Expected element or locator to be an instance of Element or Locator.') 223 225 }
+10 -3
packages/browser/src/client/tester/expect/toMatchScreenshot.ts
··· 40 40 ? nameOrOptions 41 41 : `${this.currentTestName} ${counter.current}` 42 42 43 + const [element, ...mask] = await Promise.all([ 44 + convertToSelector(actual, options), 45 + ...options.screenshotOptions && 'mask' in options.screenshotOptions 46 + ? (options.screenshotOptions.mask as Array<Element | Locator>) 47 + .map(m => convertToSelector(m, options)) 48 + : [], 49 + ]) 50 + 43 51 const normalizedOptions: Omit<ScreenshotMatcherArguments[2], 'element'> = ( 44 52 options.screenshotOptions && 'mask' in options.screenshotOptions 45 53 ? { 46 54 ...options, 47 55 screenshotOptions: { 48 56 ...options.screenshotOptions, 49 - mask: (options.screenshotOptions.mask as Array<Element | Locator>) 50 - .map(convertToSelector), 57 + mask, 51 58 }, 52 59 } 53 60 // TS believes `mask` to still be defined as `ReadonlyArray<Element | Locator>` ··· 60 67 name, 61 68 this.currentTestName, 62 69 { 63 - element: convertToSelector(actual), 70 + element, 64 71 ...normalizedOptions, 65 72 }, 66 73 ] satisfies ScreenshotMatcherArguments,
+119 -28
packages/browser/src/client/tester/locators/index.ts
··· 3 3 LocatorByRoleOptions, 4 4 LocatorOptions, 5 5 LocatorScreenshotOptions, 6 + SelectorOptions, 6 7 UserEventClearOptions, 7 8 UserEventClickOptions, 8 9 UserEventDragAndDropOptions, ··· 24 25 Ivya, 25 26 } from 'ivya' 26 27 import { page, server, utils } from 'vitest/browser' 27 - import { __INTERNAL } from 'vitest/internal/browser' 28 + import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser' 28 29 import { ensureAwaited, getBrowserState } from '../../utils' 29 - import { escapeForTextSelector, isLocator, resolveUserEventWheelOptions } from '../tester-utils' 30 + import { escapeForTextSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from '../tester-utils' 30 31 32 + export { ensureAwaited } from '../../utils' 31 33 export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils' 32 34 export { 33 35 getByAltTextSelector, ··· 40 42 } from 'ivya' 41 43 42 44 __INTERNAL._asLocator = asLocator 45 + 46 + const now = Date.now 47 + const waitForIntervals = [0, 20, 50, 100, 100, 500] 48 + 49 + function sleep(ms: number): Promise<void> { 50 + const { setTimeout } = getSafeTimers() 51 + return new Promise(resolve => setTimeout(resolve, ms)) 52 + } 43 53 44 54 // we prefer using playwright locators because they are more powerful and support Shadow DOM 45 55 export const selectorEngine: Ivya = Ivya.create({ ··· 65 75 private _parsedSelector: ParsedSelector | undefined 66 76 protected _container?: Element | undefined 67 77 protected _pwSelector?: string | undefined 78 + protected _errorSource?: Error 68 79 69 80 constructor() { 70 81 Object.defineProperty(this, kLocator, { ··· 88 99 } 89 100 90 101 public wheel(options: UserEventWheelOptions): Promise<void> { 91 - return ensureAwaited<void>(async () => { 92 - await this.triggerCommand<void>('__vitest_wheel', this.selector, resolveUserEventWheelOptions(options)) 102 + return ensureAwaited<void>(async (error) => { 103 + await getBrowserState().commands.triggerCommand<void>( 104 + '__vitest_wheel', 105 + [this.selector, resolveUserEventWheelOptions(options)], 106 + error, 107 + ) 93 108 94 109 const browser = getBrowserState().config.browser.name 95 110 ··· 121 136 } 122 137 123 138 public async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise<void> { 124 - const filesPromise = (Array.isArray(files) ? files : [files]).map(async (file) => { 125 - if (typeof file === 'string') { 126 - return file 127 - } 128 - const bas64String = await new Promise<string>((resolve, reject) => { 129 - const reader = new FileReader() 130 - reader.onload = () => resolve(reader.result as string) 131 - reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)) 132 - reader.readAsDataURL(file) 133 - }) 139 + return ensureAwaited(async (error) => { 140 + const filesPromise = (Array.isArray(files) ? files : [files]).map(async (file) => { 141 + if (typeof file === 'string') { 142 + return file 143 + } 144 + const bas64String = await new Promise<string>((resolve, reject) => { 145 + const reader = new FileReader() 146 + reader.onload = () => resolve(reader.result as string) 147 + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)) 148 + reader.readAsDataURL(file) 149 + }) 134 150 135 - return { 136 - name: file.name, 137 - mimeType: file.type, 138 - // strip prefix `data:[<media-type>][;base64],` 139 - base64: bas64String.slice(bas64String.indexOf(',') + 1), 140 - } 151 + return { 152 + name: file.name, 153 + mimeType: file.type, 154 + // strip prefix `data:[<media-type>][;base64],` 155 + base64: bas64String.slice(bas64String.indexOf(',') + 1), 156 + } 157 + }) 158 + return getBrowserState().commands.triggerCommand<void>( 159 + '__vitest_upload', 160 + [this.selector, await Promise.all(filesPromise), options], 161 + error, 162 + ) 141 163 }) 142 - return this.triggerCommand<void>('__vitest_upload', this.selector, await Promise.all(filesPromise), options) 143 164 } 144 165 145 166 public dropTo(target: Locator, options: UserEventDragAndDropOptions = {}): Promise<void> { ··· 293 314 return this.selector 294 315 } 295 316 296 - protected triggerCommand<T>(command: string, ...args: any[]): Promise<T> { 297 - const commands = getBrowserState().commands 298 - return ensureAwaited(error => commands.triggerCommand<T>( 299 - command, 300 - args, 301 - error, 302 - )) 317 + public async findElement(options_: SelectorOptions = {}): Promise<HTMLElement | SVGElement> { 318 + const options = processTimeoutOptions(options_) 319 + const timeout = options?.timeout 320 + const strict = options?.strict ?? true 321 + const startTime = now() 322 + let intervalIndex = 0 323 + while (true) { 324 + const elements = this.elements() 325 + if (elements.length === 1) { 326 + return elements[0] 327 + } 328 + if (elements.length > 1) { 329 + if (strict) { 330 + throw createStrictModeViolationError(this._pwSelector || this.selector, elements) 331 + } 332 + return elements[0] 333 + } 334 + const elapsed = now() - startTime 335 + const isLastCall = timeout != null && elapsed >= timeout 336 + if (isLastCall) { 337 + throw utils.getElementError(this._pwSelector || this.selector, this._container || document.body) 338 + } 339 + const interval = waitForIntervals[Math.min(intervalIndex++, waitForIntervals.length - 1)] 340 + const nextInterval = timeout != null 341 + ? Math.min(interval, timeout - elapsed) 342 + : interval 343 + await sleep(nextInterval) 344 + } 303 345 } 346 + 347 + protected triggerCommand<T>(command: string, ...args: any[]): Promise<T> { 348 + if (this._errorSource) { 349 + return triggerCommandWithTrace<T>({ 350 + name: command, 351 + arguments: args, 352 + errorSource: this._errorSource, 353 + }) 354 + } 355 + return ensureAwaited(error => triggerCommandWithTrace<T>({ 356 + name: command, 357 + arguments: args, 358 + errorSource: error, 359 + })) 360 + } 361 + } 362 + 363 + export function triggerCommandWithTrace<T>( 364 + options: { 365 + name: string 366 + arguments: unknown[] 367 + errorSource?: Error | undefined 368 + }, 369 + ): Promise<T> { 370 + return getBrowserState().commands.triggerCommand<T>( 371 + options.name, 372 + options.arguments, 373 + options.errorSource, 374 + ) 375 + } 376 + 377 + function createStrictModeViolationError( 378 + selector: string, 379 + matches: Element[], 380 + ) { 381 + const infos = matches.slice(0, 10).map(m => ({ 382 + preview: selectorEngine.previewNode(m), 383 + selector: selectorEngine.generateSelectorSimple(m), 384 + })) 385 + const lines = infos.map( 386 + (info, i) => 387 + `\n ${i + 1}) ${info.preview} aka ${asLocator('javascript', info.selector)}`, 388 + ) 389 + if (infos.length < matches.length) { 390 + lines.push('\n ...') 391 + } 392 + return new Error( 393 + `strict mode violation: ${asLocator('javascript', selector)} resolved to ${matches.length} elements:${lines.join('')}\n`, 394 + ) 304 395 }
+4
packages/browser/src/node/commands/screenshotMatcher/utils.ts
··· 1 + // Note: this augments `screenshotOptions` types 2 + import type {} from '@vitest/browser-playwright' 3 + 1 4 import type { BrowserCommandContext, BrowserConfigOptions } from 'vitest/node' 2 5 import type { ScreenshotMatcherOptions } from '../../../../context' 3 6 import type { ScreenshotMatcherArguments } from '../../../shared/screenshotMatcher/types' ··· 29 32 scale: 'device', 30 33 }, 31 34 timeout: 5_000, 35 + strict: true, 32 36 resolveDiffPath: ({ 33 37 arg, 34 38 ext,