Skip to content

Commit 3069e59

Browse files
hi-ogawaOpenCode
andauthored
feat(ui): persist trace view selection in URL (#10981)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent 1bbc278 commit 3069e59

6 files changed

Lines changed: 211 additions & 19 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const props = defineProps<{
99
1010
const traces = computed(() => {
1111
const traceMap = getTraceAttemptMap(props.test.artifacts)
12-
return Object.values(traceMap).map(trace => ({
12+
return [...traceMap.values()].map(trace => ({
1313
trace,
1414
label: getTraceAttemptLabel(trace),
1515
}))

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ watch([selectedStep, iframeEl], ([step, iframe]) => {
7171
// Unlike Playwright which serves snapshots via HTTP, this is fully client-side
7272
// but external resources (images, stylesheets) won't load without a server.
7373
const doc = iframe.contentDocument!
74+
// TODO: rrweb also closes and opens the document during rebuild, so this reset may be redundant.
7475
doc.open()
7576
doc.close()
7677
const mirror = createMirror()
@@ -81,6 +82,9 @@ watch([selectedStep, iframeEl], ([step, iframe]) => {
8182
mirror,
8283
UNSAFE_allowUnprotectedRebuild: true,
8384
})
85+
// Close rrweb's parser after rebuilding. During page load, leaving it open
86+
// prevents the parent load event, which browsers may show as an endless spinner.
87+
doc.close()
8488
for (const [className, ids] of Object.entries(pseudoClassIds)) {
8589
for (const id of ids) {
8690
const el = mirror.getNode(id) as HTMLElement | null

‎packages/ui/client/composables/navigation.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export function showDashboard(show: boolean) {
112112
}
113113
}
114114

115-
export function navigateTo({ file, line, view, test, column }: Params) {
115+
export function navigateTo({ file, line, view, test, column }: Omit<Params, 'traceAttempt' | 'traceStep'>) {
116116
activeFileId.value = file
117117
lineNumber.value = line
118118
columnNumber.value = column

‎packages/ui/client/composables/params.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface Params {
66
line: null | number
77
test: null | string
88
column: null | number
9+
traceAttempt: null | string
10+
traceStep: null | number
911
}
1012

1113
const params = useUrlSearchParams<Params>('hash', {
@@ -15,6 +17,8 @@ const params = useUrlSearchParams<Params>('hash', {
1517
line: null,
1618
test: null,
1719
column: null,
20+
traceAttempt: null,
21+
traceStep: null,
1822
},
1923
})
2024

@@ -23,3 +27,5 @@ export const viewMode = toRef(params, 'view')
2327
export const lineNumber = toRef(params, 'line')
2428
export const columnNumber = toRef(params, 'column')
2529
export const selectedTest = toRef(params, 'test')
30+
export const selectedTraceAttempt = toRef(params, 'traceAttempt')
31+
export const selectedTraceStep = toRef(params, 'traceStep')

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

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { RunnerTestCase, RunnerTestFile, TestArtifact } from 'vitest'
22
import type { BrowserTraceData, BrowserTraceEntry } from '../../../browser/src/client/tester/trace'
3-
import { ref, watch, watchEffect } from 'vue'
3+
import { computed, ref, watch, watchEffect } from 'vue'
44
import { getProjectConfigByName } from '~/utils/task'
55
import { browserState, client, config } from './client'
66
import { detailsPosition } from './navigation'
7-
import { selectedTest } from './params'
7+
import { selectedTest, selectedTraceAttempt, selectedTraceStep } from './params'
88

99
export interface TraceSelection {
1010
test: RunnerTestCase
@@ -82,7 +82,7 @@ function normalizeTraceEntries(entries: BrowserTraceEntry[]): NormalizedBrowserT
8282
return merged
8383
}
8484

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

97-
const merged: Record<string, NormalizedBrowserTraceData> = {}
97+
const merged = new Map<string, NormalizedBrowserTraceData>()
9898
for (const [key, traces] of Object.entries(grouped)) {
9999
const trace = traces[0]
100100
const entries = traces.flatMap(trace => trace.entries)
101-
merged[key] = {
101+
merged.set(key, {
102102
...trace,
103103
entries: normalizeTraceEntries(entries),
104-
}
104+
})
105105
}
106106
return merged
107107
}
108108

109109
export function getSelectedTrace(selection: TraceSelection): NormalizedBrowserTraceData | undefined {
110110
const attempts = getTraceAttemptMap(selection.test.artifacts)
111111
return selection.attemptKey
112-
? attempts[selection.attemptKey]
113-
: Object.values(attempts)[0]
112+
? attempts.get(selection.attemptKey)
113+
: [...attempts.values()][0]
114114
}
115115

116116
export function getTraceEditorMarkersForFile(
@@ -174,50 +174,71 @@ export function getTraceEntryClass(entry: BrowserTraceEntry) {
174174

175175
export function openTrace(trace: BrowserTraceData, test: RunnerTestCase) {
176176
detailsPosition.value = 'bottom'
177-
activeTraceView.value = {
177+
setActiveTrace({
178178
test,
179179
attemptKey: getTraceAttemptKey(trace),
180180
selectedStepIndex: 0,
181-
}
181+
})
182+
}
183+
184+
function setActiveTrace(selection: TraceSelection) {
185+
activeTraceView.value = selection
186+
selectedTraceAttempt.value = selection.attemptKey ?? null
187+
selectedTraceStep.value = selection.selectedStepIndex
182188
}
183189

184190
export function closeTrace() {
185191
activeTraceView.value = undefined
192+
selectedTraceAttempt.value = null
193+
selectedTraceStep.value = null
186194
}
187195

188196
export function selectActiveTraceStep(index: number) {
189197
const selection = activeTraceView.value
190198
if (selection) {
191199
selection.selectedStepIndex = index
200+
selectedTraceStep.value = index
192201
}
193202
}
194203

204+
// Resolve the URL-selected task only when it can be shown in the trace view.
205+
const selectedTestTask = computed(() => {
206+
const test = selectedTest.value
207+
? client.state.idMap.get(selectedTest.value)
208+
: undefined
209+
return test?.type === 'test' && isTraceViewEnabled(test.file)
210+
? test
211+
: undefined
212+
})
213+
195214
// Open/close only on selected-test navigation so the close button can clear the
196215
// trace view without being auto-opened again for the same selected test.
216+
// Flush synchronously so traceStep is set before the URL is updated.
217+
// Vueuse URL watcher pauses while writing and would otherwise miss the change.
197218
watch(selectedTest, (testId) => {
198219
if (testId) {
199-
const test = client.state.idMap.get(testId)
200-
if (test?.type === 'test' && isTraceViewEnabled(test.file)) {
220+
const test = selectedTestTask.value
221+
if (test) {
201222
// Auto-open trace view when selecting a trace-enabled test.
202-
activeTraceView.value = { test, selectedStepIndex: 0 }
223+
setActiveTrace({ test, selectedStepIndex: 0 })
203224
return
204225
}
205226
}
206227

207228
// Close trace view when navigation moves away from a trace-enabled test.
208229
closeTrace()
209-
})
230+
}, { flush: 'sync' })
210231

211232
// Keep the pane attached to the latest test object after reruns, and reset the
212233
// attempt selection because retries/repeats belong to one run.
213234
watchEffect(() => {
214235
const active = activeTraceView.value
215236
const testId = selectedTest.value
216237
if (active && testId && active.test.id === testId) {
217-
const test = client.state.idMap.get(testId)
218-
if (test?.type === 'test' && active.test !== test) {
238+
const test = selectedTestTask.value
239+
if (test && active.test !== test) {
219240
// Rerun produced a fresh test object; reset attempt selection.
220-
activeTraceView.value = { test, selectedStepIndex: 0 }
241+
setActiveTrace({ test, selectedStepIndex: 0 })
221242
}
222243
}
223244
})
@@ -241,3 +262,42 @@ export function getTraceAttemptLabel(trace: BrowserTraceData) {
241262
}
242263
return parts.join(' / ')
243264
}
265+
266+
// Restore trace URL state once its selected test becomes available.
267+
initializeTraceView()
268+
269+
function initializeTraceView() {
270+
const attemptKey = selectedTraceAttempt.value
271+
const step = selectedTraceStep.value
272+
if (!selectedTest.value || (attemptKey == null && step == null)) {
273+
return
274+
}
275+
276+
const restoreTrace = () => {
277+
const test = selectedTestTask.value
278+
if (!test) {
279+
return false
280+
}
281+
282+
const attempts = getTraceAttemptMap(test.artifacts)
283+
const selectedAttemptKey = attemptKey != null && attempts.has(attemptKey) ? attemptKey : undefined
284+
const selectedTrace = selectedAttemptKey ? attempts.get(selectedAttemptKey) : [...attempts.values()][0]
285+
const selectedStepIndex = parseTraceStep(step, selectedTrace?.entries.length ?? 0)
286+
detailsPosition.value = 'bottom'
287+
setActiveTrace({
288+
test,
289+
attemptKey: selectedAttemptKey,
290+
selectedStepIndex,
291+
})
292+
return true
293+
}
294+
295+
if (!restoreTrace()) {
296+
watch(selectedTestTask, restoreTrace, { once: true })
297+
}
298+
}
299+
300+
function parseTraceStep(value: unknown, entryCount: number): number {
301+
const step = typeof value === 'number' ? value : Number(value)
302+
return Number.isInteger(step) && step >= 0 && step < entryCount ? step : 0
303+
}

‎test/ui/test/trace.spec.ts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ test.describe('ui', () => {
6464
test('attempts', async ({ page }) => {
6565
await testAttempts(page)
6666
})
67+
68+
test('persists selection in URL', async ({ page }) => {
69+
await testPersistsSelectionInURL(page)
70+
})
71+
72+
test('persists attempt in URL', async ({ page }) => {
73+
await testPersistsAttemptInURL(page)
74+
})
6775
})
6876

6977
test.describe('html reporter', () => {
@@ -138,6 +146,14 @@ test.describe('html reporter', () => {
138146
test('attempts', async ({ page }) => {
139147
await testAttempts(page)
140148
})
149+
150+
test('persists selection in URL', async ({ page }) => {
151+
await testPersistsSelectionInURL(page)
152+
})
153+
154+
test('persists attempt in URL', async ({ page }) => {
155+
await testPersistsAttemptInURL(page)
156+
})
141157
})
142158

143159
async function testBasic(page: Page) {
@@ -363,3 +379,109 @@ async function testNested(page: Page) {
363379
'test finished',
364380
])
365381
}
382+
383+
async function testPersistsSelectionInURL(page: Page) {
384+
await openExplorerItem(page, 'simple')
385+
const testId = getHashParams(page).test
386+
expect(testId).toBeDefined()
387+
388+
const traceView = page.getByTestId('trace-view')
389+
const traceSteps = traceView.getByTestId('trace-step')
390+
const traceFrame = traceView.frameLocator('iframe')
391+
392+
// Opening a test selects its first trace step and persists it in the URL.
393+
await expect(traceView).toBeVisible()
394+
await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true')
395+
await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible()
396+
await expect.poll(() => getHashParams(page)).toMatchObject({
397+
traceStep: '0',
398+
test: testId,
399+
})
400+
expect(getHashParams(page)).not.toHaveProperty('traceAttempt')
401+
402+
// Reloading restores the auto-opened default step.
403+
await page.reload()
404+
await expect.poll(() => getHashParams(page)).toMatchObject({
405+
traceStep: '0',
406+
test: testId,
407+
})
408+
await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true')
409+
await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible()
410+
411+
// Selecting another trace step updates the URL and rendered snapshot.
412+
await traceSteps.nth(1).click()
413+
await expect.poll(() => getHashParams(page)).toMatchObject({
414+
traceStep: '1',
415+
test: testId,
416+
})
417+
expect(getHashParams(page)).not.toHaveProperty('traceAttempt')
418+
await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true')
419+
await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible()
420+
421+
// Reloading preserves the same URL, selected step, and rendered snapshot.
422+
await page.reload()
423+
await expect.poll(() => getHashParams(page)).toMatchObject({
424+
traceStep: '1',
425+
test: testId,
426+
})
427+
expect(getHashParams(page)).not.toHaveProperty('traceAttempt')
428+
await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true')
429+
await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible()
430+
431+
// Invalid attempt and step values fall back to the first available entry.
432+
const invalidSelectionUrl = new URL(page.url())
433+
const invalidParams = new URLSearchParams(invalidSelectionUrl.hash.split('?')[1])
434+
invalidParams.set('traceAttempt', 'constructor')
435+
invalidParams.set('traceStep', '999')
436+
invalidSelectionUrl.hash = `/?${invalidParams}`
437+
// Leave the app so the invalid URL exercises initialization, not hash navigation.
438+
await page.goto('about:blank')
439+
await page.goto(invalidSelectionUrl.href)
440+
await expect.poll(() => getHashParams(page)).toMatchObject({
441+
traceStep: '0',
442+
test: testId,
443+
})
444+
expect(getHashParams(page)).not.toHaveProperty('traceAttempt')
445+
await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true')
446+
await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible()
447+
448+
// Closing removes only trace state and preserves the selected test.
449+
await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click()
450+
await expect(traceView).not.toBeVisible()
451+
const params = getHashParams(page)
452+
expect(params).toMatchObject({ test: testId })
453+
expect(params).not.toHaveProperty('traceAttempt')
454+
expect(params).not.toHaveProperty('traceStep')
455+
}
456+
457+
async function testPersistsAttemptInURL(page: Page) {
458+
await openExplorerItem(page, 'retried test')
459+
const testId = getHashParams(page).test
460+
expect(testId).toBeDefined()
461+
462+
const traceView = page.getByTestId('trace-view')
463+
const traceFrame = traceView.frameLocator('iframe')
464+
465+
// Opening a retry writes its attempt key to the URL.
466+
await page.getByTestId('trace-open-button').nth(1).click()
467+
await expect.poll(() => getHashParams(page)).toMatchObject({
468+
traceAttempt: '0:1',
469+
traceStep: '0',
470+
test: testId,
471+
})
472+
await expect(traceFrame.getByText('retryCount: 1')).toBeVisible()
473+
474+
// Reloading preserves the same URL and selected retry snapshot.
475+
await page.reload()
476+
await expect.poll(() => getHashParams(page)).toMatchObject({
477+
traceAttempt: '0:1',
478+
traceStep: '0',
479+
test: testId,
480+
})
481+
await expect(traceFrame.getByText('retryCount: 1')).toBeVisible()
482+
}
483+
484+
function getHashParams(page: Page) {
485+
const hash = new URL(page.url()).hash
486+
return Object.fromEntries(new URLSearchParams(hash.split('?')[1]))
487+
}

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo