mirror of
https://github.com/hanzoai/vitest.git
synced 2026-09-24 17:04:25 +00:00
4e056ee9d7
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vladimir Sheremet <[email protected]>
211 lines
7.5 KiB
Vue
211 lines
7.5 KiB
Vue
<script setup lang="ts">
|
|
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 { getTraceEntryClass, selectActiveTraceStep } 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'
|
|
})
|
|
|
|
function onSelectStep(index: number) {
|
|
selectActiveTraceStep(index)
|
|
const step = entries.value[index]
|
|
if (step?.location) {
|
|
openLocation(props.selection.test, step.location)
|
|
}
|
|
}
|
|
|
|
watch([selectedStep, iframeEl], ([step, iframe]) => {
|
|
if (!step || !iframe) {
|
|
return
|
|
}
|
|
const { serialized, selectorId, 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!
|
|
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,
|
|
})
|
|
for (const [className, ids] of Object.entries(pseudoClassIds)) {
|
|
for (const id of ids) {
|
|
const el = mirror.getNode(id) as Element | null
|
|
if (el?.classList) {
|
|
el.classList.add(className)
|
|
}
|
|
}
|
|
}
|
|
iframe.contentWindow!.scrollTo(scroll?.x ?? 0, scroll?.y ?? 0)
|
|
if (selectorId != null) {
|
|
const el = mirror.getNode(selectorId)
|
|
if (el) {
|
|
// 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(() => {
|
|
const rect = (el as Element).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;
|
|
`
|
|
doc.documentElement.appendChild(overlay)
|
|
})
|
|
}
|
|
}
|
|
}, { immediate: true })
|
|
|
|
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'
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Splitpanes
|
|
class="h-full min-h-0"
|
|
>
|
|
<Pane :size="30" min-size="20">
|
|
<div class="h-full min-h-0 p-4" flex="~ col gap-1" overflow-auto>
|
|
<button
|
|
v-for="(step, index) of entries"
|
|
:key="index"
|
|
type="button"
|
|
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-current="selection.selectedStepIndex === index ? 'step' : undefined"
|
|
@click="onSelectStep(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 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="70" min-size="20">
|
|
<div class="h-full min-h-0" flex="~ col" overflow-auto>
|
|
<iframe
|
|
v-if="selectedStep"
|
|
ref="iframeEl"
|
|
:key="iframeSandbox"
|
|
:sandbox="iframeSandbox"
|
|
style="background: white; border: none; color-scheme: normal; flex: none"
|
|
/>
|
|
<div v-else class="text-sm opacity-50 p-4">
|
|
No trace step found
|
|
</div>
|
|
</div>
|
|
</Pane>
|
|
</Splitpanes>
|
|
</template>
|