From 8c4b6da025883893a684d123c94ee03b063d6ad1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 22 May 2026 02:36:48 +0900 Subject: [PATCH] feat(browser): show trace view steps in editor panel (#10404) Co-authored-by: Codex --- .../ui/client/components/trace/TraceView.vue | 45 +++------- .../client/components/trace/TraceViewPane.vue | 3 +- .../ui/client/components/views/ViewEditor.vue | 78 +++++++++++++++- packages/ui/client/composables/trace-view.ts | 88 +++++++++++++++++-- packages/ui/vite.config.ts | 8 ++ test/ui/fixtures/trace/vitest.config.ts | 1 + test/ui/test/trace.spec.ts | 29 +++++- 7 files changed, 206 insertions(+), 46 deletions(-) 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') -- 2.51.2