import type { Locator, SelectorOptions, SerializedLocator, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' import type { BrowserRPC } from '../client' import type { BrowserTraceEntryStatus } from './trace' import { __INTERNAL } from 'vitest/internal/browser' import { getBrowserState, getWorkerState } from '../utils' import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace' /* @__NO_SIDE_EFFECTS__ */ export function convertElementToCssSelector(element: Element): string { if (!element || !(element instanceof Element)) { throw new Error( `Expected DOM element to be an instance of Element, received ${typeof element}`, ) } return getUniqueCssSelector(element) } function escapeIdForCSSSelector(id: string) { return id .split('') .map((char) => { const code = char.charCodeAt(0) if (char === ' ' || char === '#' || char === '.' || char === ':' || char === '[' || char === ']' || char === '>' || char === '+' || char === '~' || char === '\\') { // Escape common special characters with backslashes return `\\${char}` } else if (code >= 0x10000) { // Unicode escape for characters outside the BMP return `\\${code.toString(16).toUpperCase().padStart(6, '0')} ` } else if (code < 0x20 || code === 0x7F) { // Non-printable ASCII characters (0x00-0x1F and 0x7F) are escaped return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` } else if (code >= 0x80) { // Non-ASCII characters (0x80 and above) are escaped return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` } else { // Allowable characters are used directly return char } }) .join('') } function getUniqueCssSelector(el: Element) { const path = [] let parent: null | ParentNode let hasShadowRoot = false // eslint-disable-next-line no-cond-assign while (parent = getParent(el)) { if ((parent as Element).shadowRoot) { hasShadowRoot = true } const tag = el.tagName if (el.id) { path.push(`#${escapeIdForCSSSelector(el.id)}`) } else if (!el.nextElementSibling && !el.previousElementSibling) { path.push(tag.toLowerCase()) } else { let index = 0 let sameTagSiblings = 0 let elementIndex = 0 for (const sibling of parent.children) { index++ if (sibling.tagName === tag) { sameTagSiblings++ } if (sibling === el) { elementIndex = index } } if (sameTagSiblings > 1) { path.push(`${tag.toLowerCase()}:nth-child(${elementIndex})`) } else { path.push(tag.toLowerCase()) } } el = parent as Element }; return `${getBrowserState().provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}` } function getParent(el: Element) { const parent = el.parentNode if (parent instanceof ShadowRoot) { return parent.host } return parent } const ACTION_TRACE_COMMANDS = new Set([ '__vitest_click', '__vitest_dblClick', '__vitest_tripleClick', '__vitest_wheel', '__vitest_type', '__vitest_clear', '__vitest_fill', '__vitest_selectOptions', '__vitest_dragAndDrop', '__vitest_hover', '__vitest_upload', '__vitest_tab', '__vitest_keyboard', '__vitest_takeScreenshot', ]) export class CommandsManager { private _listeners: ((command: string, args: any[]) => void)[] = [] public onCommand(listener: (command: string, args: any[]) => void): void { this._listeners.push(listener) } public async triggerCommand( command: string, args: any[], // error makes sure the stack trace is correct on webkit, // if we make the error here, it looses the context clientError: Error = new Error('empty'), ): Promise { const state = getWorkerState() const rpc = state.rpc as any as BrowserRPC const { sessionId, traces } = getBrowserState() const filepath = state.filepath || state.current?.file?.filepath args = args.filter(arg => arg !== undefined) // remove optional fields const actionTraceGroupName = ACTION_TRACE_COMMANDS.has(command) ? `vitest:${command.slice('__vitest_'.length)}` : undefined const currentTest = getWorkerState().current const hasActiveTrace = !!actionTraceGroupName && !!currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id) const hasActiveTraceView = !!actionTraceGroupName && !!currentTest && getBrowserState().browserTraceAttempts.has(currentTest.id) if (this._listeners.length) { await Promise.all(this._listeners.map(listener => listener(command, args))) } return traces.$( 'vitest.browser.tester.command', { attributes: { 'vitest.browser.command': command, 'code.file.path': filepath, }, }, async () => { if (hasActiveTrace) { await rpc.triggerCommand( sessionId, '__vitest_groupTraceStart', filepath, [{ name: actionTraceGroupName, stack: clientError.stack, }], ) } let status: BrowserTraceEntryStatus = 'pass' const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined const element = typeof args[0] === 'object' && 'selector' in args[0] && 'locator' in args[0] ? args[0] : undefined if (hasActiveTraceView) { // Covers provider-backed actionability/waiting after command dispatch. // Local pre-command resolution, such as serializeElement/findElement paths // is not covered within by this action trace range. await recordBrowserTraceEntry(currentTest, { name: actionTraceGroupName, kind: 'action', range: { id: traceRangeId!, phase: 'start' }, element, stack: clientError.stack, }) } try { return await rpc.triggerCommand(sessionId, command, filepath, args) } catch (err: any) { status = 'fail' // rethrow an error to keep the stack trace in browser clientError.message = err.message clientError.name = err.name clientError.stack = clientError.stack?.replace(clientError.message, err.message) throw clientError } finally { if (hasActiveTraceView) { await recordBrowserTraceEntry(currentTest, { name: actionTraceGroupName, kind: 'action', range: { id: traceRangeId!, phase: 'end' }, status, element, stack: clientError.stack, }) } if (hasActiveTrace) { await rpc.triggerCommand( sessionId, '__vitest_groupTraceEnd', filepath, [], ) } } }, ) } } export function getIframeScale(): number { const iframe = window.frameElement if (!iframe) { throw new Error(`Cannot find iframe element. This is a bug in Vitest. Please, open a new issue with reproduction.`) } // DOMMatrix parses the computed 2D transform matrix [a, b, c, d, e, f] // `a` and `d` are the x and y scale factors - since we only apply uniform scaling, `a === d` const scale = new DOMMatrix(getComputedStyle(iframe).transform).a return scale } function escapeRegexForSelector(re: RegExp): string { // Unicode mode does not allow "identity character escapes", so we do not escape and // hope that it does not contain quotes and/or >> signs. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape // TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}. if (re.unicode || (re as any).unicodeSets) { return String(re) } // Even number of backslashes followed by the quote -> insert a backslash. return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, '$1$2\\$3').replace(/>>/g, '\\>\\>') } export function escapeForTextSelector(text: string | RegExp, exact: boolean): string { if (typeof text !== 'string') { return escapeRegexForSelector(text) } return `${JSON.stringify(text)}${exact ? 's' : 'i'}` } const provider = getBrowserState().provider const kElementLocator = Symbol.for('$$vitest:locator-resolved') export async function serializeElement(elementOrLocator: Element | Locator, options?: SelectorOptions): Promise { if (!elementOrLocator) { throw new Error('Expected element or locator to be defined.') } if (elementOrLocator instanceof Element) { const selector = convertElementToCssSelector(elementOrLocator) return { selector, locator: __INTERNAL._asLocator('javascript', selector) } } if (isLocator(elementOrLocator)) { if (provider === 'playwright' || kElementLocator in elementOrLocator) { return elementOrLocator.serialize() } const element = await elementOrLocator.findElement(options) const selector = convertElementToCssSelector(element) const locator = __INTERNAL._asLocator('javascript', selector) return { selector, locator } } throw new Error('Expected element or locator to be an instance of Element or Locator.') } const kLocator = Symbol.for('$$vitest:locator') export function isLocator(element: unknown): element is Locator { return (!!element && typeof element === 'object' && kLocator in element) } const DEFAULT_WHEEL_DELTA = 100 export function resolveUserEventWheelOptions(options: UserEventWheelOptions): UserEventWheelDeltaOptions { let delta: UserEventWheelDeltaOptions['delta'] if (options.delta) { delta = options.delta } else { switch (options.direction) { case 'up': { delta = { y: -DEFAULT_WHEEL_DELTA } break } case 'down': { delta = { y: DEFAULT_WHEEL_DELTA } break } case 'left': { delta = { x: -DEFAULT_WHEEL_DELTA } break } case 'right': { delta = { x: DEFAULT_WHEEL_DELTA } break } } } return { delta, times: options.times, } }