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