Skip to content

Commit 8c4b6da

Browse files
hi-ogawacodex
andauthored
feat(browser): show trace view steps in editor panel (#10404)
Co-authored-by: Codex <[email protected]>
1 parent 3475fd3 commit 8c4b6da

7 files changed

Lines changed: 206 additions & 46 deletions

File tree

‎packages/ui/client/components/trace/TraceView.vue‎

Lines changed: 12 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,20 @@
11
<script setup lang="ts">
2-
import type { RunnerTestCase } from 'vitest'
32
import type { BrowserTraceData, BrowserTraceEntry } from '../../../../browser/src/client/tester/trace'
3+
import type { TraceSelection } from '~/composables/trace-view'
44
import { createCache, createMirror, rebuild } from 'rrweb-snapshot'
55
// @ts-expect-error missing types
66
import { Pane, Splitpanes } from 'splitpanes'
77
import { computed, ref, watch } from 'vue'
88
import { openLocation } from '~/composables/location'
9+
import { getTraceEntryClass, selectActiveTraceStep } from '~/composables/trace-view'
910
1011
const props = defineProps<{
1112
trace: BrowserTraceData
12-
test: RunnerTestCase
13+
selection: TraceSelection
1314
}>()
1415
1516
const entries = computed(() => props.trace.entries)
16-
17-
// preserve step on live update
18-
const selectedStepIndex = ref(0)
19-
watch([
20-
() => props.test,
21-
() => props.trace.repeats,
22-
() => props.trace.retry,
23-
], () => {
24-
selectedStepIndex.value = 0
25-
})
26-
const selectedStep = computed(() => entries.value[selectedStepIndex.value])
17+
const selectedStep = computed(() => entries.value[props.selection.selectedStepIndex])
2718
2819
const iframeEl = ref<HTMLIFrameElement>()
2920
const iframeSandbox = computed(() => {
@@ -33,10 +24,10 @@ const iframeSandbox = computed(() => {
3324
})
3425
3526
function onSelectStep(index: number) {
36-
selectedStepIndex.value = index
27+
selectActiveTraceStep(index)
3728
const step = entries.value[index]
3829
if (step?.location) {
39-
openLocation(props.test, step.location)
30+
openLocation(props.selection.test, step.location)
4031
}
4132
}
4233
@@ -100,7 +91,7 @@ watch([selectedStep, iframeEl], ([step, iframe]) => {
10091
}, { immediate: true })
10192
10293
function getStepButtonClass(step: BrowserTraceEntry, index: number) {
103-
const selected = selectedStepIndex.value === index
94+
const selected = props.selection.selectedStepIndex === index
10495
// TODO: move trace step state colors to shared semantic UI shortcuts.
10596
if (isTraceStepInProgress(step)) {
10697
return selected
@@ -142,19 +133,6 @@ function formatStepName(step: BrowserTraceEntry) {
142133
return step.name
143134
}
144135
145-
function getStepMarkerClass(step: BrowserTraceEntry) {
146-
if (step.kind === 'action') {
147-
return 'bg-blue-500/80'
148-
}
149-
if (step.kind === 'expect') {
150-
return 'bg-green-500/80'
151-
}
152-
if (step.kind === 'mark') {
153-
return 'bg-amber-500/80'
154-
}
155-
return 'bg-gray-400/80 dark:bg-gray-500/80'
156-
}
157-
158136
function isTraceStepInProgress(step: BrowserTraceEntry) {
159137
return step.range?.phase === 'start'
160138
}
@@ -174,19 +152,20 @@ function isTraceStepInProgress(step: BrowserTraceEntry) {
174152
:data-test-range="step.range?.phase"
175153
class="w-full text-left px-2 py-1 rounded text-sm"
176154
:class="getStepButtonClass(step, index)"
177-
:aria-current="selectedStepIndex === index ? 'step' : undefined"
155+
:aria-current="selection.selectedStepIndex === index ? 'step' : undefined"
178156
@click="onSelectStep(index)"
179157
>
180158
<div class="flex items-start gap-2">
181159
<span class="mt-0.5 h-4 w-4 flex flex-shrink-0 items-center justify-center">
182160
<span
183161
v-if="isTraceStepInProgress(step)"
184-
class="h-3 w-3 animate-spin rounded-full border border-yellow-500 border-t-transparent"
162+
class="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent"
163+
:class="getTraceEntryClass(step)"
185164
/>
186165
<span
187166
v-else
188-
class="h-2 w-2 rounded-full"
189-
:class="getStepMarkerClass(step)"
167+
class="h-2 w-2 rounded-full bg-current opacity-80"
168+
:class="getTraceEntryClass(step)"
190169
/>
191170
</span>
192171
<div class="min-w-0 flex-1">

‎packages/ui/client/components/trace/TraceViewPane.vue‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const props = defineProps<{
1010
}>()
1111
1212
const trace = computed(() => getSelectedTrace(props.selection))
13-
const test = computed(() => props.selection.test)
1413
const attemptLabel = computed(() => trace.value ? getTraceAttemptLabel(trace.value) : '')
1514
</script>
1615

@@ -36,7 +35,7 @@ const attemptLabel = computed(() => trace.value ? getTraceAttemptLabel(trace.val
3635
<TraceView
3736
v-if="trace"
3837
:trace="trace"
39-
:test="test"
38+
:selection="selection"
4039
/>
4140
<div v-else class="text-sm opacity-50 p-4">
4241
No trace found

‎packages/ui/client/components/views/ViewEditor.vue‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,17 @@ import { finished } from '~/composables/client/state'
1111
import { codemirrorRef } from '~/composables/codemirror'
1212
import { openInEditor } from '~/composables/error'
1313
import { columnNumber, lineNumber } from '~/composables/params'
14+
import {
15+
activeTraceView,
16+
getTraceEditorMarkersForFile,
17+
getTraceEntryClass,
18+
isTraceViewEnabled,
19+
selectActiveTraceStep,
20+
} from '~/composables/trace-view'
1421
import CodeMirrorContainer from '../CodeMirrorContainer.vue'
1522
1623
const props = defineProps<{
17-
file?: RunnerTestFile
24+
file: RunnerTestFile
1825
}>()
1926
2027
const emit = defineEmits<{ (event: 'draft', value: boolean): void }>()
@@ -139,6 +146,68 @@ function codemirrorChanges() {
139146
draft.value = serverCode.value !== codemirrorRef.value!.getValue()
140147
}
141148
149+
const TRACE_GUTTER_ID = 'trace-step-gutter'
150+
const traceGutterConfigs = isTraceViewEnabled(props.file)
151+
? [{ className: TRACE_GUTTER_ID, style: 'width: 14px' }]
152+
: []
153+
let traceGutterLines: number[] = []
154+
155+
const traceEditorMarkersForFile = computed(() => {
156+
const selection = activeTraceView.value
157+
const file = props.file?.filepath
158+
if (selection && file) {
159+
return getTraceEditorMarkersForFile(selection, file)
160+
}
161+
return []
162+
})
163+
164+
function syncTraceMarkers() {
165+
const editor = codemirrorRef.value
166+
if (!editor) {
167+
return
168+
}
169+
170+
for (const line of traceGutterLines) {
171+
editor.setGutterMarker(line, TRACE_GUTTER_ID, null)
172+
}
173+
traceGutterLines = []
174+
175+
const lineCount = editor.lineCount()
176+
for (const marker of traceEditorMarkersForFile.value) {
177+
const line = marker.line - 1
178+
if (!(line >= 0 && line < lineCount)) {
179+
continue
180+
}
181+
const el = document.createElement('button')
182+
el.type = 'button'
183+
el.className = [
184+
'h-2 w-2 ml-0.5 cursor-pointer rounded-full bg-current',
185+
getTraceEntryClass(marker.entry),
186+
marker.active
187+
? 'ring-2 ring-current ring-offset-1 ring-offset-white dark:ring-offset-gray-900'
188+
: 'opacity-75 scale-120',
189+
].filter(Boolean).join(' ')
190+
el.dataset.testid = 'trace-editor-marker'
191+
el.ariaLabel = `Select trace step: ${marker.entry.name}`
192+
if (marker.active) {
193+
el.ariaCurrent = 'step'
194+
}
195+
el.addEventListener('click', () => {
196+
selectActiveTraceStep(marker.stepIndex)
197+
})
198+
editor.setGutterMarker(line, TRACE_GUTTER_ID, el)
199+
traceGutterLines.push(line)
200+
}
201+
}
202+
203+
watch(
204+
[codemirrorRef, traceEditorMarkersForFile, loading],
205+
() => {
206+
syncTraceMarkers()
207+
},
208+
{ immediate: true },
209+
)
210+
142211
watch(
143212
draft,
144213
(d) => {
@@ -385,7 +454,12 @@ onBeforeUnmount(clearListeners)
385454
ref="editor"
386455
v-model="code"
387456
h-full
388-
v-bind="{ lineNumbers: true, readOnly: isReport || !config.api?.allowWrite, saving }"
457+
v-bind="{
458+
lineNumbers: true,
459+
readOnly: isReport || !config.api?.allowWrite,
460+
saving,
461+
gutters: ['CodeMirror-linenumbers', ...traceGutterConfigs],
462+
}"
389463
:mode="ext"
390464
data-testid="code-mirror"
391465
@save="onSave"

‎packages/ui/client/composables/trace-view.ts‎

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RunnerTestCase, TestArtifact } from 'vitest'
1+
import type { RunnerTestCase, RunnerTestFile, TestArtifact } from 'vitest'
22
import type { BrowserTraceData, BrowserTraceEntry } from '../../../browser/src/client/tester/trace'
33
import { ref, watch, watchEffect } from 'vue'
44
import { browserState, client, config } from './client'
@@ -8,6 +8,15 @@ import { selectedTest } from './params'
88
export interface TraceSelection {
99
test: RunnerTestCase
1010
attemptKey?: string
11+
selectedStepIndex: number
12+
}
13+
14+
export interface TraceEditorMarker {
15+
file: string
16+
line: number
17+
stepIndex: number
18+
entry: BrowserTraceEntry
19+
active?: boolean
1120
}
1221

1322
export const activeTraceView = ref<TraceSelection>()
@@ -85,26 +94,93 @@ export function getSelectedTrace(selection: TraceSelection): BrowserTraceData |
8594
: Object.values(attempts)[0]
8695
}
8796

97+
export function getTraceEditorMarkersForFile(
98+
selection: TraceSelection,
99+
file: string,
100+
): TraceEditorMarker[] {
101+
const trace = getSelectedTrace(selection)
102+
return getTraceEditorMarkers(trace?.entries ?? [])
103+
.filter(marker => marker.file === file)
104+
.map(marker => ({
105+
...marker,
106+
active: marker.stepIndex === selection.selectedStepIndex,
107+
}))
108+
}
109+
110+
function getTraceEditorMarkers(entries: BrowserTraceEntry[]): TraceEditorMarker[] {
111+
const markers: TraceEditorMarker[] = []
112+
const seen = new Set<string>()
113+
114+
for (const [stepIndex, entry] of entries.entries()) {
115+
const location = entry.location
116+
if (!location) {
117+
continue
118+
}
119+
120+
const key = `${location.file}:${location.line}`
121+
if (seen.has(key)) {
122+
continue
123+
}
124+
125+
seen.add(key)
126+
markers.push({
127+
file: location.file,
128+
line: location.line,
129+
stepIndex,
130+
entry,
131+
})
132+
}
133+
134+
return markers
135+
}
136+
137+
export function getTraceEntryClass(entry: BrowserTraceEntry) {
138+
if (entry.range?.phase === 'start') {
139+
return 'text-yellow-500'
140+
}
141+
if (entry.status === 'fail') {
142+
return 'text-red-500'
143+
}
144+
if (entry.kind === 'action') {
145+
return 'text-blue-500'
146+
}
147+
if (entry.kind === 'expect') {
148+
return 'text-green-500'
149+
}
150+
if (entry.kind === 'mark') {
151+
return 'text-amber-500'
152+
}
153+
return 'text-gray-400 dark:text-gray-500'
154+
}
155+
88156
export function openTrace(trace: BrowserTraceData, test: RunnerTestCase) {
89157
detailsPosition.value = 'bottom'
90158
activeTraceView.value = {
91159
test,
92160
attemptKey: getTraceAttemptKey(trace),
161+
selectedStepIndex: 0,
93162
}
94163
}
95164

96165
export function closeTrace() {
97166
activeTraceView.value = undefined
98167
}
99168

169+
export function selectActiveTraceStep(index: number) {
170+
const selection = activeTraceView.value
171+
if (selection) {
172+
selection.selectedStepIndex = index
173+
}
174+
}
175+
100176
// Open/close only on selected-test navigation so the close button can clear the
101177
// trace view without being auto-opened again for the same selected test.
102178
watch(selectedTest, (testId) => {
103179
if (testId) {
104180
const test = client.state.idMap.get(testId)
105-
if (test?.type === 'test' && isTraceViewEnabled(test)) {
181+
if (test?.type === 'test' && isTraceViewEnabled(test.file)) {
106182
// Auto-open trace view when selecting a trace-enabled test.
107-
activeTraceView.value = { test }
183+
activeTraceView.value = { test, selectedStepIndex: 0 }
108184
return
109185
}
110186
}
@@ -122,12 +198,12 @@ watchEffect(() => {
122198
const test = client.state.idMap.get(testId)
123199
if (test?.type === 'test' && active.test !== test) {
124200
// Rerun produced a fresh test object; reset attempt selection.
125-
activeTraceView.value = { test }
201+
activeTraceView.value = { test, selectedStepIndex: 0 }
126202
}
127203
}
128204
})
129205

130-
function isTraceViewEnabled(test: RunnerTestCase): boolean {
206+
export function isTraceViewEnabled(test: RunnerTestFile): boolean {
131207
const project = getProjectConfigByTest(test)
132208
const traceView
133209
= browserState?.config.browser?.traceView
@@ -136,7 +212,7 @@ function isTraceViewEnabled(test: RunnerTestCase): boolean {
136212
return traceView?.enabled ?? false
137213
}
138214

139-
function getProjectConfigByTest(test: RunnerTestCase) {
215+
function getProjectConfigByTest(test: RunnerTestFile) {
140216
const projectName = test.file.projectName || ''
141217
return config.value.projects?.find(project => project.name === projectName)
142218
}

‎packages/ui/vite.config.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export default defineConfig({
2020
Vue(),
2121
Unocss({
2222
presets: [presetWind3(), presetAttributify(), presetIcons()],
23+
content: {
24+
pipeline: {
25+
include: [
26+
// by default .ts is excluded
27+
/\/client\/.*\.(ts|vue)($|\?)/,
28+
],
29+
},
30+
},
2331
shortcuts: {
2432
'bg-base': 'bg-white dark:bg-[#111]',
2533
'bg-overlay': 'bg-[#eee]:50 dark:bg-[#222]:50',

‎test/ui/fixtures/trace/vitest.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mousedownCommand: BrowserCommand<[selector: string]> = async (ctx, selecto
1212
export default defineConfig({
1313
test: {
1414
ui: true,
15+
includeTaskLocation: true,
1516
browser: {
1617
enabled: true,
1718
provider: playwright(),

0 commit comments

Comments
 (0)