import type { RunnerTask as Task } from 'vitest' import type { BrowserTraceEntryKind } from 'vitest/browser' import type { BrowserRPC } from '../client' import type { SerializedLocator } from './locators' import { getBrowserState, getWorkerState, now } from '../utils' export interface BrowserTraceData { retry: number repeats: number // UI has access to original config but let artifact own this recordCanvas: boolean // Each artifact currently carries one entry; the UI merges entries by attempt. // TODO: revisit whether this should be modeled as a single entry. entries: BrowserTraceEntry[] } export type BrowserTraceEntryStatus = 'pass' | 'fail' type BrowserTraceEntryRangePhase = 'start' | 'end' type BrowserTraceSelectorResolution = 'matched' | 'missing' | 'error' interface BrowserTraceEntryRange { id: string phase: BrowserTraceEntryRangePhase } export interface BrowserTraceEntry { name: string kind: BrowserTraceEntryKind range?: BrowserTraceEntryRange status?: BrowserTraceEntryStatus startTime: number // Derived on UI side from range start/end entries. duration?: number stack?: string // resolved server-side from stack in __vitest_recordBrowserTrace command location?: { file: string; line: number; column: number } element?: SerializedLocator snapshot: TraceSnapshot } interface TraceSnapshot { serialized: unknown viewport: { width: number height: number } scroll: { x: number y: number } selectorIds?: number[] // not used yet for UI but tested selectorResolution?: BrowserTraceSelectorResolution selectorError?: string pseudoClassIds: Record } // Dynamic pseudo-class state isn't preserved in the serialized DOM. rrweb-snapshot // rewrites user-action selectors in serialized styles. For example: // some-selector:hover { ... } // becomes: // some-selector:hover, some-selector.\:hover { ... } // Vitest side integration then adds matching pseudo-state classes in the replay DOM. // rrweb-snapshot only handles `:hover` upstream, so we patch it locally for the // other user-action pseudo-classes as well. Native states such as `:popover-open` // are restored through their DOM API instead. // https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-classes#user_action_pseudo-classes const PSEUDO_CLASS_NAMES = [ ':hover', ':active', ':focus', ':focus-visible', ':focus-within', ':popover-open', ] as const type PseudoClassName = (typeof PSEUDO_CLASS_NAMES)[number] export interface BrowserTraceAttempt { retry: number repeats: number startTime: number } export function createBrowserTraceRangeId(): string { return Math.random().toString(36).slice(2) } export async function recordBrowserTraceEntry( task: Task, options: Omit, ): Promise { const attemptInfo = getBrowserState().browserTraceAttempts.get(task.id)! const relativeStartTime = now() - attemptInfo.startTime const snapshot = takeSnapshot(options.element) const entry: BrowserTraceEntry = { ...options, startTime: relativeStartTime, snapshot, } const { retry, repeats } = attemptInfo const { recordCanvas } = getBrowserState().config.browser.traceView // An async lane could defer artifact recording and flush it at test-attempt end, // but the synchronous snapshot work is already a comparable cost, and this path // is mostly data passing after that. // Keep it simple unless measurements show artifact recording is a bottleneck. const data: BrowserTraceData = { retry, repeats, recordCanvas, entries: [entry], } const rpc = getWorkerState().rpc as any as BrowserRPC await rpc.triggerCommand( getBrowserState().sessionId, '__vitest_recordBrowserTrace', undefined, [{ testId: task.id, data }], ) } // Resolve ivya selector to a DOM element and take a snapshot with rrweb Mirror // so we can store the nodeId for provider-agnostic element highlighting in the viewer. // Note: Playwright's alternative approach is to store the raw selector and run the // selector engine inside the snapshot iframe at view time via injected script. // Our approach resolves at collection time (same moment as snapshot) — simpler but // requires Mirror plumbing. nodeId-based lookup also works across shadow DOM, unlike querySelector. function takeSnapshot(serializedLocator?: SerializedLocator): TraceSnapshot { const { snapshot, createMirror } = getBrowserState().browserTraceDomSnapshot! const traceView = getBrowserState().config.browser.traceView const engine = getBrowserState().selectorEngine! const mirror = createMirror() const serialized = snapshot(document, { mirror, inlineImages: traceView.inlineImages, recordCanvas: traceView.recordCanvas, }) const result: TraceSnapshot = { serialized, viewport: { width: window.innerWidth, height: window.innerHeight, }, scroll: { x: window.scrollX, y: window.scrollY, }, pseudoClassIds: {} as any, } for (const className of PSEUDO_CLASS_NAMES) { const elements = document.querySelectorAll(className) const ids = Array.from(elements, (el) => mirror.getId(el)).filter((id) => id !== -1) result.pseudoClassIds[className] = ids } if (serializedLocator) { try { const elements = engine.querySelectorAll( engine.parseSelector(serializedLocator._pwSelector ?? serializedLocator.selector), document.documentElement, ) const ids = elements.map((el) => mirror.getId(el)).filter((id) => id !== -1) if (ids.length > 0) { result.selectorIds = ids result.selectorResolution = 'matched' } else { result.selectorResolution = 'missing' } } catch (error) { result.selectorResolution = 'error' result.selectorError = error instanceof Error ? error.message : String(error) } } return result }