Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e850ee3
feat(ui): persist trace selection in URL
hi-ogawa Aug 18, 2026
1b7d618
Merge remote-tracking branch 'upstream/main' into fix/issue-10769
hi-ogawa Aug 18, 2026
c60eca3
Merge branch 'main' into fix/issue-10769
hi-ogawa Aug 19, 2026
62ba71a
Merge branch 'main' into fix/issue-10769
hi-ogawa Aug 19, 2026
23f9dcc
test(ui): update trace selection assertion
hi-ogawa Aug 19, 2026
9b5db55
test(ui): cover live trace URL state
hi-ogawa Aug 19, 2026
c467a21
nit
hi-ogawa Aug 19, 2026
2a05d45
refactor(ui): qualify trace URL params
hi-ogawa Aug 19, 2026
21d7faa
test(ui): simplify trace URL assertions
hi-ogawa Aug 19, 2026
858cd85
test(ui): parse trace params from page URL
hi-ogawa Aug 19, 2026
6c1a11b
test(ui): explain trace URL scenarios
hi-ogawa Aug 19, 2026
41e2e3d
nit
hi-ogawa Aug 19, 2026
8ca9b63
test(ui): retain exact trace test ID
hi-ogawa Aug 19, 2026
6b871f5
test(ui): contrast trace URL states
hi-ogawa Aug 19, 2026
94554ad
test(ui): construct invalid trace URL locally
hi-ogawa Aug 19, 2026
fcdfaea
test(ui): contrast trace attempt states
hi-ogawa Aug 19, 2026
6a8f18f
test(ui): align trace persistence checks
hi-ogawa Aug 19, 2026
ff911a8
nit
hi-ogawa Aug 19, 2026
4459f4c
nit
hi-ogawa Aug 19, 2026
c86e2bb
chore(ui): fix URL param lint
hi-ogawa Aug 19, 2026
0039e0c
refactor(ui): pass complete trace selection
hi-ogawa Aug 19, 2026
ac5d8e6
refactor(ui): keep default trace attempt implicit
hi-ogawa Aug 19, 2026
4a78da9
refactor(ui): inline default trace selection
hi-ogawa Aug 19, 2026
bef9ded
fix(ui): preserve implicit trace attempt
hi-ogawa Aug 19, 2026
b3695cb
refactor(ui): simplify config readiness check
hi-ogawa Aug 21, 2026
fcfbbce
refactor(ui): use map for trace attempts
hi-ogawa Aug 21, 2026
061ea52
refactor(ui): derive selected trace test
hi-ogawa Aug 21, 2026
fbe61ce
Merge branch 'main' into fix/issue-10769
hi-ogawa Aug 21, 2026
136a924
refactor(ui): camelcase trace URL params
hi-ogawa Aug 21, 2026
42a1fea
fix(ui): close rebuilt trace document
hi-ogawa Aug 21, 2026
b89ac56
nit
hi-ogawa Aug 21, 2026
bd2f221
refactor(ui): own trace view initialization
hi-ogawa Aug 21, 2026
045da8d
feat(ui): persist trace pane sizes
hi-ogawa Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/ui/client/components/trace/TraceArtifacts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const props = defineProps<{

const traces = computed(() => {
const traceMap = getTraceAttemptMap(props.test.artifacts)
return Object.values(traceMap).map(trace => ({
return [...traceMap.values()].map(trace => ({
trace,
label: getTraceAttemptLabel(trace),
}))
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/client/components/trace/TraceView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ watch([selectedStep, iframeEl], ([step, iframe]) => {
// 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!
// TODO: rrweb also closes and opens the document during rebuild, so this reset may be redundant.
doc.open()
doc.close()
const mirror = createMirror()
Expand All @@ -81,6 +82,9 @@ watch([selectedStep, iframeEl], ([step, iframe]) => {
mirror,
UNSAFE_allowUnprotectedRebuild: true,
})
// Close rrweb's parser after rebuilding. During page load, leaving it open
// prevents the parent load event, which browsers may show as an endless spinner.
doc.close()
for (const [className, ids] of Object.entries(pseudoClassIds)) {
for (const id of ids) {
const el = mirror.getNode(id) as Element | null
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/client/composables/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const detailSizes = useLocalStorage<[left: number, right: number]>(
67,
],
)
export const traceSizes = useLocalStorage<[browser: number, trace: number]>(
'vitest-ui_splitpanes-traceSizes',
[55, 45],
)

export const detailsPanelVisible = useLocalStorage<boolean>(
'vitest-ui_details-panel-visible',
Expand Down Expand Up @@ -112,7 +116,7 @@ export function showDashboard(show: boolean) {
}
}

export function navigateTo({ file, line, view, test, column }: Params) {
export function navigateTo({ file, line, view, test, column }: Omit<Params, 'traceAttempt' | 'traceStep'>) {
activeFileId.value = file
lineNumber.value = line
columnNumber.value = column
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/client/composables/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface Params {
line: null | number
test: null | string
column: null | number
traceAttempt: null | string
traceStep: null | number
}

const params = useUrlSearchParams<Params>('hash', {
Expand All @@ -15,6 +17,8 @@ const params = useUrlSearchParams<Params>('hash', {
line: null,
test: null,
column: null,
traceAttempt: null,
traceStep: null,
},
})

Expand All @@ -23,3 +27,5 @@ export const viewMode = toRef(params, 'view')
export const lineNumber = toRef(params, 'line')
export const columnNumber = toRef(params, 'column')
export const selectedTest = toRef(params, 'test')
export const selectedTraceAttempt = toRef(params, 'traceAttempt')
export const selectedTraceStep = toRef(params, 'traceStep')
90 changes: 74 additions & 16 deletions packages/ui/client/composables/trace-view.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { RunnerTestCase, RunnerTestFile, TestArtifact } from 'vitest'
import type { BrowserTraceData, BrowserTraceEntry } from '../../../browser/src/client/tester/trace'
import { ref, watch, watchEffect } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
import { getProjectConfigByName } from '~/utils/task'
import { browserState, client, config } from './client'
import { detailsPosition } from './navigation'
import { selectedTest } from './params'
import { selectedTest, selectedTraceAttempt, selectedTraceStep } from './params'

export interface TraceSelection {
test: RunnerTestCase
Expand Down Expand Up @@ -82,7 +82,7 @@ function normalizeTraceEntries(entries: BrowserTraceEntry[]): NormalizedBrowserT
return merged
}

export function getTraceAttemptMap(artifacts: TestArtifact[]): Record<string, NormalizedBrowserTraceData> {
export function getTraceAttemptMap(artifacts: TestArtifact[]): Map<string, NormalizedBrowserTraceData> {
const grouped: Record<string, BrowserTraceData[]> = {}
for (const artifact of artifacts) {
if (artifact.type !== 'internal:browserTrace') {
Expand All @@ -94,23 +94,23 @@ export function getTraceAttemptMap(artifacts: TestArtifact[]): Record<string, No
grouped[key].push(trace)
}

const merged: Record<string, NormalizedBrowserTraceData> = {}
const merged = new Map<string, NormalizedBrowserTraceData>()
for (const [key, traces] of Object.entries(grouped)) {
const trace = traces[0]
const entries = traces.flatMap(trace => trace.entries)
merged[key] = {
merged.set(key, {
...trace,
entries: normalizeTraceEntries(entries),
}
})
}
return merged
}

export function getSelectedTrace(selection: TraceSelection): NormalizedBrowserTraceData | undefined {
const attempts = getTraceAttemptMap(selection.test.artifacts)
return selection.attemptKey
? attempts[selection.attemptKey]
: Object.values(attempts)[0]
? attempts.get(selection.attemptKey)
: [...attempts.values()][0]
}

export function getTraceEditorMarkersForFile(
Expand Down Expand Up @@ -174,32 +174,51 @@ export function getTraceEntryClass(entry: BrowserTraceEntry) {

export function openTrace(trace: BrowserTraceData, test: RunnerTestCase) {
detailsPosition.value = 'bottom'
activeTraceView.value = {
setActiveTrace({
test,
attemptKey: getTraceAttemptKey(trace),
selectedStepIndex: 0,
}
})
}

function setActiveTrace(selection: TraceSelection) {
activeTraceView.value = selection
selectedTraceAttempt.value = selection.attemptKey ?? null
selectedTraceStep.value = selection.selectedStepIndex
}

export function closeTrace() {
activeTraceView.value = undefined
selectedTraceAttempt.value = null
selectedTraceStep.value = null
}

export function selectActiveTraceStep(index: number) {
const selection = activeTraceView.value
if (selection) {
selection.selectedStepIndex = index
selectedTraceStep.value = index
}
}

// Resolve the URL-selected task only when it can be shown in the trace view.
const selectedTestTask = computed(() => {
const test = selectedTest.value
? client.state.idMap.get(selectedTest.value)
: undefined
return test?.type === 'test' && isTraceViewEnabled(test.file)
? test
: undefined
})

// 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.file)) {
const test = selectedTestTask.value
if (test) {
// Auto-open trace view when selecting a trace-enabled test.
activeTraceView.value = { test, selectedStepIndex: 0 }
setActiveTrace({ test, selectedStepIndex: 0 })
return
}
}
Expand All @@ -214,10 +233,10 @@ watchEffect(() => {
const active = activeTraceView.value
const testId = selectedTest.value
if (active && testId && active.test.id === testId) {
const test = client.state.idMap.get(testId)
if (test?.type === 'test' && active.test !== test) {
const test = selectedTestTask.value
if (test && active.test !== test) {
// Rerun produced a fresh test object; reset attempt selection.
activeTraceView.value = { test, selectedStepIndex: 0 }
setActiveTrace({ test, selectedStepIndex: 0 })
}
}
})
Expand All @@ -241,3 +260,42 @@ export function getTraceAttemptLabel(trace: BrowserTraceData) {
}
return parts.join(' / ')
}

// Restore trace URL state once its selected test becomes available.
initializeTraceView()

function initializeTraceView() {
const attemptKey = selectedTraceAttempt.value
const step = selectedTraceStep.value
if (!selectedTest.value || (attemptKey == null && step == null)) {
return
}

const restoreTrace = () => {
const test = selectedTestTask.value
if (!test) {
return false
}

const attempts = getTraceAttemptMap(test.artifacts)
const selectedAttemptKey = attemptKey != null && attempts.has(attemptKey) ? attemptKey : undefined
const selectedTrace = selectedAttemptKey ? attempts.get(selectedAttemptKey) : [...attempts.values()][0]
const selectedStepIndex = parseTraceStep(step, selectedTrace?.entries.length ?? 0)
detailsPosition.value = 'bottom'
setActiveTrace({
test,
attemptKey: selectedAttemptKey,
selectedStepIndex,
})
return true
}

if (!restoreTrace()) {
watch(selectedTestTask, restoreTrace, { once: true })
}
}

function parseTraceStep(value: unknown, entryCount: number): number {
const step = typeof value === 'number' ? value : Number(value)
return Number.isInteger(step) && step >= 0 && step < entryCount ? step : 0
}
16 changes: 14 additions & 2 deletions packages/ui/client/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
initializeNavigation,
mainSizes,
panels,
traceSizes,
} from '~/composables/navigation'
import { activeTraceView } from '~/composables/trace-view'

Expand Down Expand Up @@ -46,6 +47,15 @@ const onModuleResized = useDebounceFn(({ panes }: { panes: { size: number }[] })
allowBrowserEvents()
}, 0)

const onTraceResized = useDebounceFn(({ panes }: { panes: { size: number }[] }) => {
if (panes.length === 2) {
panes.forEach((pane, index) => {
traceSizes.value[index] = pane.size
})
}
allowBrowserEvents()
}, 0)

const resizingMain = useDebounceFn(({ panes }: { panes: { size: number }[] }) => {
recordMainResize(panes)
preventBrowserEvents()
Expand Down Expand Up @@ -119,11 +129,13 @@ function allowBrowserEvents() {
v-if="browserState.config.browser?.traceView.enabled"
class="h-full"
:horizontal="detailsPosition === 'right'"
@resize="preventBrowserEvents"
@resized="onTraceResized"
>
<Pane :size="activeTraceView ? 55 : 100" min-size="10">
<Pane :size="activeTraceView ? traceSizes[0] : 100" min-size="10">
<BrowserIframe v-once />
</Pane>
<Pane v-if="activeTraceView" size="45" min-size="10">
<Pane v-if="activeTraceView" :size="traceSizes[1]" min-size="10">
<TraceViewPane :selection="activeTraceView" />
</Pane>
</Splitpanes>
Expand Down
Loading
Loading