Files
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Vladimir Sheremet
4e056ee9d7 fix(deps): update all non-major dependencies (#9958)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Vladimir Sheremet <[email protected]>
2026-07-28 12:34:05 +02:00

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>