import type { ParsedSelector } from 'ivya' import type { LocatorByRoleOptions, LocatorFilterOptions, LocatorOptions, LocatorScreenshotOptions, MarkOptions, SelectorOptions, UserEventClearOptions, UserEventClickOptions, UserEventDragAndDropOptions, UserEventFillOptions, UserEventHoverOptions, UserEventSelectOptions, UserEventUploadOptions, UserEventWheelOptions, } from 'vitest/browser' import { asLocator, getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector, Ivya, } from 'ivya' import { page, server, utils } from 'vitest/browser' import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser' import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' import { LocatorAction, resolveActionTimeout, UploadAction } from './action' import { convertElementToCssSelector, escapeForTextSelector, isLocator, resolveUserEventWheelOptions, } from './tester-utils' import { recordBrowserTraceEntry } from './trace' export { ensureAwaited } from '../utils' export { processTimeoutOptions } from './action' export { convertElementToCssSelector, getIframeScale } from './tester-utils' export { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector, } from 'ivya' __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)) } export const selectorEngine: Ivya = Ivya.create({ exact: server.config.browser.locators.exact, browser: ((name: string) => { switch (name) { case 'edge': case 'chrome': return 'chromium' case 'safari': return 'webkit' default: return name as 'webkit' | 'firefox' | 'chromium' } })(server.config.browser.name), testIdAttribute: server.config.browser.locators.testIdAttribute, }) getBrowserState().selectorEngine = selectorEngine const kLocator = Symbol.for('$$vitest:locator') export abstract class Locator { public abstract selector: string private _parsedSelector: ParsedSelector | undefined protected _container?: Element | undefined protected _pwSelector?: string | undefined protected _pwLocator?: string | undefined protected _errorSource?: Error constructor() { Object.defineProperty(this, kLocator, { enumerable: false, writable: false, configurable: false, value: kLocator, }) } public click(options?: UserEventClickOptions): Promise { return this.action('__vitest_click', [], options) } public dblClick(options?: UserEventClickOptions): Promise { return this.action('__vitest_dblClick', [], options) } public tripleClick(options?: UserEventClickOptions): Promise { return this.action('__vitest_tripleClick', [], options) } public wheel(options: UserEventWheelOptions): Promise { return ensureAwaited(async (error) => { await getBrowserState().commands.triggerCommand( '__vitest_wheel', [this.serialize(), resolveUserEventWheelOptions(options)], error, ) const browser = getBrowserState().config.browser.name // looks like on Chromium the scroll event gets dispatched a frame later if (browser === 'chromium' || browser === 'chrome') { return new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) } }) } public clear(options?: UserEventClearOptions): Promise { return this.action('__vitest_clear', [], options) } public hover(options?: UserEventHoverOptions): Promise { return this.action('__vitest_hover', [], options) } public unhover(options?: UserEventHoverOptions): Promise { return this.action('__vitest_hover', [], options, { selector: 'html > body', locator: "locator('body')", }) } public fill(text: string, options?: UserEventFillOptions): Promise { return this.action('__vitest_fill', [text], options) } public upload( files: string | string[] | File | File[], options?: UserEventUploadOptions, ): Promise { return new UploadAction(this.serialize(), files, options, this._errorSource) } public dropTo(target: Locator, options: UserEventDragAndDropOptions = {}): Promise { return this.action('__vitest_dragAndDrop', [target.toJSON()], options, this.toJSON()) } public selectOptions( value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], options?: UserEventSelectOptions, ): Promise { const values = (Array.isArray(value) ? value : [value]).map((v) => { if (typeof v !== 'string') { const element: SerializedLocator = isLocator(v) ? v.serialize() : { selector: convertElementToCssSelector(v), locator: __INTERNAL._asLocator( 'javascript', selectorEngine.generateSelectorSimple(v), ), } return { element } } return v }) return this.action('__vitest_selectOptions', [values], options) } public screenshot(options: Omit & { base64: true }): Promise<{ path: string base64: string }> public screenshot(options?: LocatorScreenshotOptions): Promise public screenshot(options?: LocatorScreenshotOptions): Promise< | string | { path: string base64: string } > { return page.screenshot({ ...options, element: this, }) } public mark(name: string, options?: MarkOptions): Promise { const currentTest = getWorkerState().current const hasActiveTrace = !!currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id) const hasActiveTraceView = !!currentTest && getBrowserState().browserTraceAttempts.has(currentTest.id) if (!currentTest || (!hasActiveTrace && !hasActiveTraceView)) { return Promise.resolve() } return ensureAwaited(async (error) => { if (hasActiveTraceView) { await recordBrowserTraceEntry(currentTest, { name, kind: options?.kind ?? 'mark', element: this.serialize(), stack: options?.stack ?? error?.stack, }) } if (!hasActiveTrace) { return Promise.resolve() } return getBrowserState().commands.triggerCommand( '__vitest_markTrace', [ { name, element: this.serialize(), stack: options?.stack ?? error?.stack, }, ], error, ) }) } protected abstract locator(selector: string): Locator protected abstract elementLocator(element: Element): Locator public getByRole(role: string, options?: LocatorByRoleOptions): Locator { return this.locator(getByRoleSelector(role, options)) } public getByAltText(text: string | RegExp, options?: LocatorOptions): Locator { return this.locator(getByAltTextSelector(text, options)) } public getByLabelText(text: string | RegExp, options?: LocatorOptions): Locator { return this.locator(getByLabelSelector(text, options)) } public getByPlaceholder(text: string | RegExp, options?: LocatorOptions): Locator { return this.locator(getByPlaceholderSelector(text, options)) } public getByTestId(testId: string | RegExp): Locator { return this.locator(getByTestIdSelector(server.config.browser.locators.testIdAttribute, testId)) } public getByText(text: string | RegExp, options?: LocatorOptions): Locator { return this.locator(getByTextSelector(text, options)) } public getByTitle(title: string | RegExp, options?: LocatorOptions): Locator { return this.locator(getByTitleSelector(title, options)) } public filter(filter: LocatorFilterOptions): Locator { const selectors = [] if (filter?.hasText) { selectors.push(`internal:has-text=${escapeForTextSelector(filter.hasText, false)}`) } if (filter?.hasNotText) { selectors.push(`internal:has-not-text=${escapeForTextSelector(filter.hasNotText, false)}`) } if (filter?.has) { const locator = filter.has as Locator selectors.push(`internal:has=${JSON.stringify(locator._pwSelector || locator.selector)}`) } if (filter?.hasNot) { const locator = filter.hasNot as Locator selectors.push(`internal:has-not=${JSON.stringify(locator._pwSelector || locator.selector)}`) } if (!selectors.length) { throw new Error(`Locator.filter expects at least one filter. None provided.`) } return this.locator(selectors.join(' >> ')) } public and(locator: Locator): Locator { return this.locator(`internal:and=${JSON.stringify(locator._pwSelector || locator.selector)}`) } public or(locator: Locator): Locator { return this.locator(`internal:or=${JSON.stringify(locator._pwSelector || locator.selector)}`) } public query(): HTMLElement | SVGElement | null { const parsedSelector = this._parsedSelector || (this._parsedSelector = selectorEngine.parseSelector(this._pwSelector || this.selector)) return selectorEngine.querySelector(parsedSelector, document.documentElement, true) as | HTMLElement | SVGElement } public element(): HTMLElement | SVGElement { const element = this.query() if (!element) { throw utils.getElementError(this, this._container || document.body) } return element } public elements(): (HTMLElement | SVGElement)[] { const parsedSelector = this._parsedSelector || (this._parsedSelector = selectorEngine.parseSelector(this._pwSelector || this.selector)) return selectorEngine.querySelectorAll(parsedSelector, document.documentElement) as ( | HTMLElement | SVGElement )[] } public get length(): number { return this.elements().length } public all(): Locator[] { return this.elements().map((element) => this.elementLocator(element)) } public nth(index: number): Locator { return this.locator(`nth=${index}`) } public first(): Locator { return this.nth(0) } public last(): Locator { return this.nth(-1) } public toString(): string { return this.selector } public serialize(): SerializedLocator { return { selector: this.selector, locator: this.asLocator(), _pwSelector: this._pwSelector, } } public asLocator(): string { return ( this._pwLocator || (this._pwLocator = asLocator('javascript', this._pwSelector || this.selector)) ) } public toJSON(): SerializedLocator { return this.serialize() } public async findElement(options_: SelectorOptions = {}): Promise { const timeout = resolveActionTimeout(options_) 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, elements) } return elements[0] } const elapsed = now() - startTime const isLastCall = timeout != null && elapsed >= timeout if (isLastCall) { throw utils.getElementError(this, 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) } } private action( command: string, args: unknown[], options?: { timeout?: number }, target: SerializedLocator = this.serialize(), ): Promise { return new LocatorAction(target, command, args, options, this._errorSource) } } /** * @deprecated */ export function triggerCommandWithTrace(options: { name: string arguments: unknown[] errorSource?: Error | undefined }): Promise { return getBrowserState().commands.triggerCommand( options.name, options.arguments, options.errorSource, ) } export interface SerializedLocator { selector: string locator: string /** * @internal */ _pwSelector?: string } function createStrictModeViolationError(locator: Locator, 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: ${locator.asLocator()} resolved to ${matches.length} elements:${lines.join('')}\n`, ) }