diff --git a/packages/ui/client/components/trace/TraceView.vue b/packages/ui/client/components/trace/TraceView.vue
index 16bdfec79..a6e320f74 100644
--- a/packages/ui/client/components/trace/TraceView.vue
+++ b/packages/ui/client/components/trace/TraceView.vue
@@ -1,29 +1,20 @@
@@ -36,7 +35,7 @@ const attemptLabel = computed(() => trace.value ? getTraceAttemptLabel(trace.val
No trace found
diff --git a/packages/ui/client/components/views/ViewEditor.vue b/packages/ui/client/components/views/ViewEditor.vue
index 2d8c0fe1e..500c602d6 100644
--- a/packages/ui/client/components/views/ViewEditor.vue
+++ b/packages/ui/client/components/views/ViewEditor.vue
@@ -11,10 +11,17 @@ import { finished } from '~/composables/client/state'
import { codemirrorRef } from '~/composables/codemirror'
import { openInEditor } from '~/composables/error'
import { columnNumber, lineNumber } from '~/composables/params'
+import {
+ activeTraceView,
+ getTraceEditorMarkersForFile,
+ getTraceEntryClass,
+ isTraceViewEnabled,
+ selectActiveTraceStep,
+} from '~/composables/trace-view'
import CodeMirrorContainer from '../CodeMirrorContainer.vue'
const props = defineProps<{
- file?: RunnerTestFile
+ file: RunnerTestFile
}>()
const emit = defineEmits<{ (event: 'draft', value: boolean): void }>()
@@ -139,6 +146,68 @@ function codemirrorChanges() {
draft.value = serverCode.value !== codemirrorRef.value!.getValue()
}
+const TRACE_GUTTER_ID = 'trace-step-gutter'
+const traceGutterConfigs = isTraceViewEnabled(props.file)
+ ? [{ className: TRACE_GUTTER_ID, style: 'width: 14px' }]
+ : []
+let traceGutterLines: number[] = []
+
+const traceEditorMarkersForFile = computed(() => {
+ const selection = activeTraceView.value
+ const file = props.file?.filepath
+ if (selection && file) {
+ return getTraceEditorMarkersForFile(selection, file)
+ }
+ return []
+})
+
+function syncTraceMarkers() {
+ const editor = codemirrorRef.value
+ if (!editor) {
+ return
+ }
+
+ for (const line of traceGutterLines) {
+ editor.setGutterMarker(line, TRACE_GUTTER_ID, null)
+ }
+ traceGutterLines = []
+
+ const lineCount = editor.lineCount()
+ for (const marker of traceEditorMarkersForFile.value) {
+ const line = marker.line - 1
+ if (!(line >= 0 && line < lineCount)) {
+ continue
+ }
+ const el = document.createElement('button')
+ el.type = 'button'
+ el.className = [
+ 'h-2 w-2 ml-0.5 cursor-pointer rounded-full bg-current',
+ getTraceEntryClass(marker.entry),
+ marker.active
+ ? 'ring-2 ring-current ring-offset-1 ring-offset-white dark:ring-offset-gray-900'
+ : 'opacity-75 scale-120',
+ ].filter(Boolean).join(' ')
+ el.dataset.testid = 'trace-editor-marker'
+ el.ariaLabel = `Select trace step: ${marker.entry.name}`
+ if (marker.active) {
+ el.ariaCurrent = 'step'
+ }
+ el.addEventListener('click', () => {
+ selectActiveTraceStep(marker.stepIndex)
+ })
+ editor.setGutterMarker(line, TRACE_GUTTER_ID, el)
+ traceGutterLines.push(line)
+ }
+}
+
+watch(
+ [codemirrorRef, traceEditorMarkersForFile, loading],
+ () => {
+ syncTraceMarkers()
+ },
+ { immediate: true },
+)
+
watch(
draft,
(d) => {
@@ -385,7 +454,12 @@ onBeforeUnmount(clearListeners)
ref="editor"
v-model="code"
h-full
- v-bind="{ lineNumbers: true, readOnly: isReport || !config.api?.allowWrite, saving }"
+ v-bind="{
+ lineNumbers: true,
+ readOnly: isReport || !config.api?.allowWrite,
+ saving,
+ gutters: ['CodeMirror-linenumbers', ...traceGutterConfigs],
+ }"
:mode="ext"
data-testid="code-mirror"
@save="onSave"
diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts
index e07ac9695..83cf03118 100644
--- a/packages/ui/client/composables/trace-view.ts
+++ b/packages/ui/client/composables/trace-view.ts
@@ -1,4 +1,4 @@
-import type { RunnerTestCase, TestArtifact } from 'vitest'
+import type { RunnerTestCase, RunnerTestFile, TestArtifact } from 'vitest'
import type { BrowserTraceData, BrowserTraceEntry } from '../../../browser/src/client/tester/trace'
import { ref, watch, watchEffect } from 'vue'
import { browserState, client, config } from './client'
@@ -8,6 +8,15 @@ import { selectedTest } from './params'
export interface TraceSelection {
test: RunnerTestCase
attemptKey?: string
+ selectedStepIndex: number
+}
+
+export interface TraceEditorMarker {
+ file: string
+ line: number
+ stepIndex: number
+ entry: BrowserTraceEntry
+ active?: boolean
}
export const activeTraceView = ref()
@@ -85,11 +94,71 @@ export function getSelectedTrace(selection: TraceSelection): BrowserTraceData |
: Object.values(attempts)[0]
}
+export function getTraceEditorMarkersForFile(
+ selection: TraceSelection,
+ file: string,
+): TraceEditorMarker[] {
+ const trace = getSelectedTrace(selection)
+ return getTraceEditorMarkers(trace?.entries ?? [])
+ .filter(marker => marker.file === file)
+ .map(marker => ({
+ ...marker,
+ active: marker.stepIndex === selection.selectedStepIndex,
+ }))
+}
+
+function getTraceEditorMarkers(entries: BrowserTraceEntry[]): TraceEditorMarker[] {
+ const markers: TraceEditorMarker[] = []
+ const seen = new Set()
+
+ for (const [stepIndex, entry] of entries.entries()) {
+ const location = entry.location
+ if (!location) {
+ continue
+ }
+
+ const key = `${location.file}:${location.line}`
+ if (seen.has(key)) {
+ continue
+ }
+
+ seen.add(key)
+ markers.push({
+ file: location.file,
+ line: location.line,
+ stepIndex,
+ entry,
+ })
+ }
+
+ return markers
+}
+
+export function getTraceEntryClass(entry: BrowserTraceEntry) {
+ if (entry.range?.phase === 'start') {
+ return 'text-yellow-500'
+ }
+ if (entry.status === 'fail') {
+ return 'text-red-500'
+ }
+ if (entry.kind === 'action') {
+ return 'text-blue-500'
+ }
+ if (entry.kind === 'expect') {
+ return 'text-green-500'
+ }
+ if (entry.kind === 'mark') {
+ return 'text-amber-500'
+ }
+ return 'text-gray-400 dark:text-gray-500'
+}
+
export function openTrace(trace: BrowserTraceData, test: RunnerTestCase) {
detailsPosition.value = 'bottom'
activeTraceView.value = {
test,
attemptKey: getTraceAttemptKey(trace),
+ selectedStepIndex: 0,
}
}
@@ -97,14 +166,21 @@ export function closeTrace() {
activeTraceView.value = undefined
}
+export function selectActiveTraceStep(index: number) {
+ const selection = activeTraceView.value
+ if (selection) {
+ selection.selectedStepIndex = index
+ }
+}
+
// Open/close only on selected-test navigation so the close button can clear the
// trace view without being auto-opened again for the same selected test.
watch(selectedTest, (testId) => {
if (testId) {
const test = client.state.idMap.get(testId)
- if (test?.type === 'test' && isTraceViewEnabled(test)) {
+ if (test?.type === 'test' && isTraceViewEnabled(test.file)) {
// Auto-open trace view when selecting a trace-enabled test.
- activeTraceView.value = { test }
+ activeTraceView.value = { test, selectedStepIndex: 0 }
return
}
}
@@ -122,12 +198,12 @@ watchEffect(() => {
const test = client.state.idMap.get(testId)
if (test?.type === 'test' && active.test !== test) {
// Rerun produced a fresh test object; reset attempt selection.
- activeTraceView.value = { test }
+ activeTraceView.value = { test, selectedStepIndex: 0 }
}
}
})
-function isTraceViewEnabled(test: RunnerTestCase): boolean {
+export function isTraceViewEnabled(test: RunnerTestFile): boolean {
const project = getProjectConfigByTest(test)
const traceView
= browserState?.config.browser?.traceView
@@ -136,7 +212,7 @@ function isTraceViewEnabled(test: RunnerTestCase): boolean {
return traceView?.enabled ?? false
}
-function getProjectConfigByTest(test: RunnerTestCase) {
+function getProjectConfigByTest(test: RunnerTestFile) {
const projectName = test.file.projectName || ''
return config.value.projects?.find(project => project.name === projectName)
}
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 0dce02afb..ed9a9e066 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -20,6 +20,14 @@ export default defineConfig({
Vue(),
Unocss({
presets: [presetWind3(), presetAttributify(), presetIcons()],
+ content: {
+ pipeline: {
+ include: [
+ // by default .ts is excluded
+ /\/client\/.*\.(ts|vue)($|\?)/,
+ ],
+ },
+ },
shortcuts: {
'bg-base': 'bg-white dark:bg-[#111]',
'bg-overlay': 'bg-[#eee]:50 dark:bg-[#222]:50',
diff --git a/test/ui/fixtures/trace/vitest.config.ts b/test/ui/fixtures/trace/vitest.config.ts
index 5e0411568..14883027a 100644
--- a/test/ui/fixtures/trace/vitest.config.ts
+++ b/test/ui/fixtures/trace/vitest.config.ts
@@ -12,6 +12,7 @@ const mousedownCommand: BrowserCommand<[selector: string]> = async (ctx, selecto
export default defineConfig({
test: {
ui: true,
+ includeTaskLocation: true,
browser: {
enabled: true,
provider: playwright(),
diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts
index 8aad9c5f3..54f72bc8d 100644
--- a/test/ui/test/trace.spec.ts
+++ b/test/ui/test/trace.spec.ts
@@ -131,16 +131,29 @@ async function testBasic(page: Page) {
await openExplorerItem(page, 'simple')
await expect(traceView).toBeVisible()
+ const traceSteps = traceView.getByTestId('trace-step')
+ const traceStepNames = traceView.getByTestId('trace-step-name')
+ await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([
+ 'Render simple',
+ 'Render another',
+ 'test finished',
+ ])
+
// selecting steps should open source code view
- const traceSteps = traceView.getByTestId('trace-step-name')
await expect(page.getByTestId('btn-report')).toContainClass('tab-button-active')
- await traceSteps.getByText('Render simple').click()
+ await traceStepNames.getByText('Render simple').click()
await expect(page.getByTestId('btn-code')).toContainClass('tab-button-active')
// verify editor cursor position
const getEditorCursor = () => evaluateEditor(page, editor => editor.getCursor())
await expect.poll(() => getEditorCursor()).toEqual({ line: 9, ch: 32 })
+ // markers ordered by 'test finished' > 'Render simple' > 'Render another'
+ const traceEditorMarkers = page.getByTestId('editor').getByTestId('trace-editor-marker')
+ await expect(traceEditorMarkers).toHaveCount(3)
+ await expect(traceEditorMarkers.nth(1)).toHaveAttribute('aria-current', 'step')
+ await expect(traceEditorMarkers.nth(2)).not.toHaveAttribute('aria-current', 'step')
+
// verify snapshot replay in iframe
const traceFrame = traceView.frameLocator('iframe')
await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible()
@@ -149,9 +162,19 @@ async function testBasic(page: Page) {
await expect(traceFrame.getByTestId('trace-view-highlight')).toBeVisible()
// selecting 2nd trace step and verify again
- await traceSteps.getByText('Render another').click()
+ await traceStepNames.getByText('Render another').click()
await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible()
await expect.poll(() => getEditorCursor()).toEqual({ line: 12, ch: 32 })
+ await expect(traceSteps.nth(1)).toHaveAttribute('aria-current', 'step')
+ await expect(traceEditorMarkers.nth(1)).not.toHaveAttribute('aria-current', 'step')
+ await expect(traceEditorMarkers.nth(2)).toHaveAttribute('aria-current', 'step')
+
+ // selecting 1st trace step from editor and verify again
+ await traceEditorMarkers.nth(1).click()
+ await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible()
+ await expect(traceEditorMarkers.nth(1)).toHaveAttribute('aria-current', 'step')
+ await expect(traceEditorMarkers.nth(2)).not.toHaveAttribute('aria-current', 'step')
+ await expect(traceSteps.nth(0)).toHaveAttribute('aria-current', 'step')
// verify selecting another test switches trace viewer
await openExplorerItem(page, 'switch-target')