Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288<script setup lang="ts">import type { SplitpanesResizedPayload } from 'splitpanes'import type { NormalizedBrowserTraceData, NormalizedBrowserTraceEntry, TraceSelection,} from '~/composables/trace-view'import { createCache, createMirror, rebuild } from 'rrweb-snapshot'import { Pane, Splitpanes } from 'splitpanes'import { computed, ref, watch } from 'vue'import { openLocation } from '~/composables/location'import { traceViewSplitSizes } from '~/composables/navigation'import { getTraceEntryClass, selectActiveTraceStep, showTraceSelectorHighlight, useTraceZoom,} from '~/composables/trace-view'
const props = defineProps<{ trace: NormalizedBrowserTraceData selection: TraceSelection}>()
const entries = computed(() => props.trace.entries)const selectedStep = computed(() => entries.value[props.selection.selectedStepIndex])
const iframeEl = ref<HTMLIFrameElement>()const iframeSandbox = computed(() => { // Canvas replay needs scripts for rrweb's image.onload -> drawImage path, // but allow-same-origin + allow-scripts gives replayed app HTML more capability. return props.trace.recordCanvas ? 'allow-same-origin allow-scripts' : 'allow-same-origin'})
const { level: zoomLevel, setFitContainer } = useTraceZoom()const viewport = computed(() => selectedStep.value?.snapshot.viewport)const scaledViewportStyle = computed(() => viewport.value ? { width: `${Math.floor(viewport.value.width * zoomLevel.value)}px`, height: `${Math.floor(viewport.value.height * zoomLevel.value)}px`, } : undefined,)
function onSelectStep(index: number) { selectActiveTraceStep(index) const step = entries.value[index] if (step?.location) { openLocation(props.selection.test, step.location) }}
function onStepKeydown(event: KeyboardEvent, index: number) { let nextIndex: number if (event.key === 'ArrowUp') { nextIndex = Math.max(index - 1, 0) } else if (event.key === 'ArrowDown') { nextIndex = Math.min(index + 1, entries.value.length - 1) } else if (event.key === 'Home') { nextIndex = 0 } else if (event.key === 'End') { nextIndex = entries.value.length - 1 } else { return }
event.preventDefault() if (nextIndex === index) { return } onSelectStep(nextIndex) const nextButton = (event.currentTarget as HTMLButtonElement).parentElement?.children[nextIndex] if (nextButton instanceof HTMLButtonElement) { nextButton.focus() }}
watch( [selectedStep, iframeEl], ([step, iframe]) => { if (!step || !iframe) { return } const { serialized, selectorIds, viewport, scroll, pseudoClassIds } = step.snapshot iframe.style.width = `${viewport.width}px` iframe.style.height = `${viewport.height}px` // Rebuild snapshot into iframe contentDocument — pattern from rrweb replayer: // https://github.com/rrweb-io/rrweb/blob/master/packages/rrweb/src/replay/index.ts // doc.open/close resets the iframe document to a blank state before rebuild. // Unlike Playwright which serves snapshots via HTTP, this is fully client-side // but external resources (images, stylesheets) won't load without a server. const doc = iframe.contentDocument! // TODO: rrweb also closes and opens the document during rebuild, so this reset may be redundant. doc.open() doc.close() const mirror = createMirror() // rrweb >=2.0 hardened the API to force sandbox iframe usage https://github.com/rrweb-io/rrweb/issues/1817. We already ensure the same manually so opt-out the guard by UNSAFE_allowUnprotectedRebuild rebuild(serialized, { doc, cache: createCache(), mirror, UNSAFE_allowUnprotectedRebuild: true, }) // Close rrweb's parser after rebuilding. During page load, leaving it open // prevents the parent load event, which browsers may show as an endless spinner. doc.close() for (const [className, ids] of Object.entries(pseudoClassIds)) { for (const id of ids) { const el = mirror.getNode(id) as HTMLElement | null if (className === ':popover-open') { el?.showPopover?.() } else if (el?.classList) { el.classList.add(className) } } } iframe.contentWindow!.scrollTo(scroll?.x ?? 0, scroll?.y ?? 0) const elements = (selectorIds ?? []) .map((id) => mirror.getNode(id) as Element | null) .filter((el) => el != null) if (elements.length > 0) { // Overlay highlight technique adapted from Playwright's highlight.ts: // https://github.com/microsoft/playwright/blob/main/packages/injected/src/highlight.ts // getBoundingClientRect() gives viewport-relative coords; position:fixed overlay matches. // Simplified version: no shadow DOM glass pane, no tooltip. iframe.contentWindow!.requestAnimationFrame(() => { for (const el of elements) { const rect = el.getBoundingClientRect() const overlay = doc.createElement('div') overlay.setAttribute('data-testid', 'trace-view-highlight') overlay.style.cssText = ` position: fixed; pointer-events: none; z-index: 2147483647; left: ${rect.left}px; top: ${rect.top}px; width: ${rect.width}px; height: ${rect.height}px; background: rgba(59, 130, 246, 0.15); border: 2px solid #3b82f6; box-sizing: border-box; ` overlay.style.display = showTraceSelectorHighlight.value ? '' : 'none' doc.documentElement.appendChild(overlay) } }) } }, { immediate: true },)
watch(showTraceSelectorHighlight, (show) => { const overlays = iframeEl.value?.contentDocument?.querySelectorAll<HTMLElement>( '[data-testid="trace-view-highlight"]', ) for (const overlay of overlays ?? []) { overlay.style.display = show ? '' : 'none' }})
function getStepButtonClass(step: NormalizedBrowserTraceEntry, index: number) { const selected = props.selection.selectedStepIndex === index // TODO: move trace step state colors to shared semantic UI shortcuts. if (isTraceStepInProgress(step)) { return selected ? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400' : 'text-yellow-700 hover:bg-yellow-500/10 dark:text-yellow-400' } if (step.status === 'fail') { return selected ? 'bg-red-500/20 text-red-600 dark:text-red-400' : 'text-red-600 hover:bg-red-500/10 dark:text-red-400' } return selected ? 'bg-blue-500/20' : 'hover:bg-gray/10'}
function formatTraceTime(ms: number) { return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`}
function formatTraceTiming(step: NormalizedBrowserTraceEntry) { if (isTraceStepInProgress(step)) { return 'running' }
const startTime = `+${formatTraceTime(step.startTime)}` return step.duration == null ? startTime : `${startTime} · ${formatTraceTime(step.duration)}`}
function formatStepName(step: NormalizedBrowserTraceEntry) { if (step.name === 'vitest:onAfterRetryTask') { return 'test finished' } if (step.name.startsWith('vitest:')) { return step.name.slice('vitest:'.length) } return step.name}
function isTraceStepInProgress(step: NormalizedBrowserTraceEntry) { return step.range?.phase === 'start'}
function onSplitpanesResized({ panes }: SplitpanesResizedPayload) { if (panes.length === 2) { traceViewSplitSizes.value = [panes[0].size, panes[1].size] }}</script>
<template> <Splitpanes class="h-full min-h-0" @resized="onSplitpanesResized"> <Pane :size="traceViewSplitSizes[0]" min-size="20"> <div class="h-full min-h-0 p-4 flex flex-col gap-1 overflow-auto" role="listbox" aria-label="Trace steps" > <button v-for="(step, index) of entries" :key="index" type="button" role="option" data-testid="trace-step" :data-test-range="step.range?.phase" class="relative w-full text-left px-2 py-1 rounded text-sm" :class="getStepButtonClass(step, index)" :style="{ paddingInlineStart: `${0.5 + step.depth}rem` }" :aria-selected="selection.selectedStepIndex === index" :tabindex="selection.selectedStepIndex === index ? 0 : -1" @click="onSelectStep(index)" @keydown="onStepKeydown($event, index)" > <span v-if="step.depth > 0" class="absolute bottom-1 top-1 border-l border-gray/40 dark:border-gray/50" :style="{ insetInlineStart: `${step.depth - 0.05}rem` }" /> <div class="flex items-start gap-2"> <span class="mt-0.5 h-4 w-4 flex flex-shrink-0 items-center justify-center"> <span v-if="isTraceStepInProgress(step)" class="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent" :class="getTraceEntryClass(step)" /> <span v-else class="h-2 w-2 rounded-full bg-current opacity-80" :class="getTraceEntryClass(step)" /> </span> <div class="min-w-0 flex-1"> <div class="truncate" data-testid="trace-step-name"> {{ formatStepName(step) }} </div> <div class="text-xs opacity-60 truncate"> {{ formatTraceTiming(step) }} </div> <div v-if="step.element" class="font-mono text-xs opacity-70 truncate"> {{ step.element.locator }} </div> </div> </div> </button> </div> </Pane> <Pane :size="traceViewSplitSizes[1]" min-size="20"> <div :ref="setFitContainer" class="h-full min-h-0 flex flex-col overflow-auto"> <div v-if="selectedStep" class="mx-auto flex-none overflow-hidden" :style="scaledViewportStyle" > <iframe ref="iframeEl" :key="iframeSandbox" :sandbox="iframeSandbox" style="background: white; border: none; color-scheme: normal; transform-origin: 0 0" :style="{ transform: `scale(${zoomLevel})` }" /> </div> <div v-else class="text-sm opacity-50 p-4">No trace step found</div> </div> </Pane> </Splitpanes></template>