From e850ee3ad1cba4fcef4ae8e22429f14a832c4ce3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:08:12 +0900 Subject: [PATCH 01/42] feat(ui): persist trace selection in URL Co-authored-by: OpenCode --- packages/ui/client/composables/navigation.ts | 2 +- packages/ui/client/composables/params.ts | 6 ++ packages/ui/client/composables/trace-view.ts | 73 +++++++++++++++++-- packages/ui/client/pages/index.vue | 3 +- test/ui/test/trace.spec.ts | 76 ++++++++++++++++++++ 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/packages/ui/client/composables/navigation.ts b/packages/ui/client/composables/navigation.ts index bcbdeb069215..48ae41e7d71c 100644 --- a/packages/ui/client/composables/navigation.ts +++ b/packages/ui/client/composables/navigation.ts @@ -112,7 +112,7 @@ export function showDashboard(show: boolean) { } } -export function navigateTo({ file, line, view, test, column }: Params) { +export function navigateTo({ file, line, view, test, column }: Omit) { activeFileId.value = file lineNumber.value = line columnNumber.value = column diff --git a/packages/ui/client/composables/params.ts b/packages/ui/client/composables/params.ts index 9b1fc51f3f60..6136e7690878 100644 --- a/packages/ui/client/composables/params.ts +++ b/packages/ui/client/composables/params.ts @@ -1,18 +1,22 @@ import { toRef, useUrlSearchParams } from '@vueuse/core' export interface Params { + attempt: null | string file: string view: null | 'graph' | 'editor' | 'console' line: null | number + step: null | number test: null | string column: null | number } export const params = useUrlSearchParams('hash', { initialValue: { + attempt: null, file: '', view: null, line: null, + step: null, test: null, column: null, }, @@ -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, 'attempt') +export const selectedTraceStep = toRef(params, 'step') diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index de024b0e66d0..1acfac53491c 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -3,7 +3,7 @@ import type { BrowserTraceData, BrowserTraceEntry } from '../../../browser/src/c import { ref, watch, watchEffect } from 'vue' 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 @@ -173,22 +173,83 @@ export function getTraceEntryClass(entry: BrowserTraceEntry) { export function openTrace(trace: BrowserTraceData, test: RunnerTestCase) { detailsPosition.value = 'bottom' + setActiveTrace(test, getTraceAttemptKey(trace), 0) +} + +function setActiveTrace(test: RunnerTestCase, attemptKey: string | undefined, stepIndex: number) { activeTraceView.value = { test, - attemptKey: getTraceAttemptKey(trace), - selectedStepIndex: 0, + attemptKey, + selectedStepIndex: stepIndex, } + selectedTraceAttempt.value = attemptKey ?? null + selectedTraceStep.value = stepIndex } 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 + } +} + +export function initializeTraceView() { + const attemptKey = selectedTraceAttempt.value + const step = selectedTraceStep.value + if (attemptKey == null && step == null) { + return + } + + const testId = selectedTest.value + if (!testId) { + closeTrace() + return + } + + const restoreTrace = () => { + if (selectedTest.value !== testId) { + return true + } + if (!Object.hasOwn(config.value, 'root')) { + return false + } + + const test = client.state.idMap.get(testId) + if (test?.type !== 'test' || !isTraceViewEnabled(test.file)) { + closeTrace() + return true + } + + const attempts = getTraceAttemptMap(test.artifacts) + const trace = attemptKey && Object.hasOwn(attempts, attemptKey) ? attempts[attemptKey] : undefined + const selectedTrace = trace ?? Object.values(attempts)[0] + const selectedStepIndex = parseTraceStep(step, selectedTrace?.entries.length ?? 0) + detailsPosition.value = 'bottom' + setActiveTrace(test, selectedTrace ? getTraceAttemptKey(selectedTrace) : undefined, selectedStepIndex) + return true } + + if (restoreTrace()) { + return + } + + const stop = watch([() => client.state.idMap.get(testId), config, selectedTest], () => { + if (restoreTrace()) { + stop() + } + }) +} + +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 } // Open/close only on selected-test navigation so the close button can clear the @@ -198,7 +259,8 @@ watch(selectedTest, (testId) => { const test = client.state.idMap.get(testId) if (test?.type === 'test' && isTraceViewEnabled(test.file)) { // Auto-open trace view when selecting a trace-enabled test. - activeTraceView.value = { test, selectedStepIndex: 0 } + const trace = Object.values(getTraceAttemptMap(test.artifacts))[0] + setActiveTrace(test, trace ? getTraceAttemptKey(trace) : undefined, 0) return } } @@ -216,7 +278,8 @@ 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, selectedStepIndex: 0 } + const trace = Object.values(getTraceAttemptMap(test.artifacts))[0] + setActiveTrace(test, trace ? getTraceAttemptKey(trace) : undefined, 0) } } }) diff --git a/packages/ui/client/pages/index.vue b/packages/ui/client/pages/index.vue index f4bcfc8ef848..4869a4a5d8b4 100644 --- a/packages/ui/client/pages/index.vue +++ b/packages/ui/client/pages/index.vue @@ -21,9 +21,10 @@ import { mainSizes, panels, } from '~/composables/navigation' -import { activeTraceView } from '~/composables/trace-view' +import { activeTraceView, initializeTraceView } from '~/composables/trace-view' const dashboardVisible = initializeNavigation() +initializeTraceView() const onBrowserPanelResizing = useDebounceFn(({ panes }: { panes: { size: number }[] }) => { // don't trigger events in the iframe while resizing diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index cc9147153dcc..9d338704480d 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -130,8 +130,84 @@ test.describe('html reporter', () => { test('attempts', async ({ page }) => { await testAttempts(page) }) + + test('persists selection in URL', async ({ page }) => { + await openExplorerItem(page, 'simple') + + const traceView = page.getByTestId('trace-view') + const traceSteps = traceView.getByTestId('trace-step') + await traceSteps.nth(1).click() + + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:0', + hasTest: true, + step: '1', + }) + + await page.reload() + + await expect(traceView).toBeVisible() + await expect(traceSteps.nth(1)).toHaveAttribute('aria-current', 'step') + await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() + + const invalidSelectionUrl = await page.evaluate(() => { + const params = new URLSearchParams(location.hash.split('?')[1]) + params.set('attempt', 'constructor') + params.set('step', '999') + return `${location.origin}${location.pathname}${location.search}#/?${params}` + }) + await page.goto('about:blank') + await page.goto(invalidSelectionUrl) + + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:0', + hasTest: true, + step: '0', + }) + await expect(traceSteps.nth(0)).toHaveAttribute('aria-current', 'step') + await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() + + await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: null, + hasTest: true, + step: null, + }) + }) + + test('persists attempt in URL', async ({ page }) => { + await openExplorerItem(page, 'retried test') + await page.getByTestId('trace-open-button').nth(1).click() + + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:1', + hasTest: true, + step: '0', + }) + + await page.reload() + + const traceView = page.getByTestId('trace-view') + await expect(traceView.frameLocator('iframe').getByText('retryCount: 1')).toBeVisible() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:1', + hasTest: true, + step: '0', + }) + }) }) +async function getTraceHashParams(page: Page) { + return page.evaluate(() => { + const params = new URLSearchParams(location.hash.split('?')[1]) + return { + attempt: params.get('attempt'), + hasTest: params.has('test'), + step: params.get('step'), + } + }) +} + async function testBasic(page: Page) { // selecting test case opens trace viewer const traceView = page.getByTestId('trace-view') From 23f9dcc1d39e883e9f083f0db0002b8361f67201 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:02:42 +0900 Subject: [PATCH 02/42] test(ui): update trace selection assertion Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index a8f8230765a8..615bfb4a4740 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -147,7 +147,7 @@ test.describe('html reporter', () => { await page.reload() await expect(traceView).toBeVisible() - await expect(traceSteps.nth(1)).toHaveAttribute('aria-current', 'step') + await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() const invalidSelectionUrl = await page.evaluate(() => { @@ -164,7 +164,7 @@ test.describe('html reporter', () => { hasTest: true, step: '0', }) - await expect(traceSteps.nth(0)).toHaveAttribute('aria-current', 'step') + await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() From 9b5db55dfd30bba8d0524fd2b4787568beba27ae Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:04:06 +0900 Subject: [PATCH 03/42] test(ui): cover live trace URL state Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 120 +++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 52 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 615bfb4a4740..5f3e7b8df829 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -60,6 +60,14 @@ test.describe('ui', () => { test('attempts', async ({ page }) => { await testAttempts(page) }) + + test('persists selection in URL', async ({ page }) => { + await testPersistsSelectionInURL(page) + }) + + test('persists attempt in URL', async ({ page }) => { + await testPersistsAttemptInURL(page) + }) }) test.describe('html reporter', () => { @@ -132,70 +140,78 @@ test.describe('html reporter', () => { }) test('persists selection in URL', async ({ page }) => { - await openExplorerItem(page, 'simple') + await testPersistsSelectionInURL(page) + }) - const traceView = page.getByTestId('trace-view') - const traceSteps = traceView.getByTestId('trace-step') - await traceSteps.nth(1).click() + test('persists attempt in URL', async ({ page }) => { + await testPersistsAttemptInURL(page) + }) +}) - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:0', - hasTest: true, - step: '1', - }) +async function testPersistsSelectionInURL(page: Page) { + await openExplorerItem(page, 'simple') - await page.reload() + const traceView = page.getByTestId('trace-view') + const traceSteps = traceView.getByTestId('trace-step') + await traceSteps.nth(1).click() - await expect(traceView).toBeVisible() - await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') - await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:0', + hasTest: true, + step: '1', + }) - const invalidSelectionUrl = await page.evaluate(() => { - const params = new URLSearchParams(location.hash.split('?')[1]) - params.set('attempt', 'constructor') - params.set('step', '999') - return `${location.origin}${location.pathname}${location.search}#/?${params}` - }) - await page.goto('about:blank') - await page.goto(invalidSelectionUrl) + await page.reload() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:0', - hasTest: true, - step: '0', - }) - await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') - await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() - - await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: null, - hasTest: true, - step: null, - }) + await expect(traceView).toBeVisible() + await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') + await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() + + const invalidSelectionUrl = await page.evaluate(() => { + const params = new URLSearchParams(location.hash.split('?')[1]) + params.set('attempt', 'constructor') + params.set('step', '999') + return `${location.origin}${location.pathname}${location.search}#/?${params}` }) + await page.goto('about:blank') + await page.goto(invalidSelectionUrl) - test('persists attempt in URL', async ({ page }) => { - await openExplorerItem(page, 'retried test') - await page.getByTestId('trace-open-button').nth(1).click() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:0', + hasTest: true, + step: '0', + }) + await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') + await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:1', - hasTest: true, - step: '0', - }) + await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: null, + hasTest: true, + step: null, + }) +} - await page.reload() +async function testPersistsAttemptInURL(page: Page) { + await openExplorerItem(page, 'retried test') + await page.getByTestId('trace-open-button').nth(1).click() - const traceView = page.getByTestId('trace-view') - await expect(traceView.frameLocator('iframe').getByText('retryCount: 1')).toBeVisible() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:1', - hasTest: true, - step: '0', - }) + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:1', + hasTest: true, + step: '0', }) -}) + + await page.reload() + + const traceView = page.getByTestId('trace-view') + await expect(traceView.frameLocator('iframe').getByText('retryCount: 1')).toBeVisible() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:1', + hasTest: true, + step: '0', + }) +} async function getTraceHashParams(page: Page) { return page.evaluate(() => { From c467a21e76c64e2b577693b6d4da1254defc1433 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:06:37 +0900 Subject: [PATCH 04/42] nit --- test/ui/test/trace.spec.ts | 152 ++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 5f3e7b8df829..7d4edae96f5c 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -148,82 +148,6 @@ test.describe('html reporter', () => { }) }) -async function testPersistsSelectionInURL(page: Page) { - await openExplorerItem(page, 'simple') - - const traceView = page.getByTestId('trace-view') - const traceSteps = traceView.getByTestId('trace-step') - await traceSteps.nth(1).click() - - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:0', - hasTest: true, - step: '1', - }) - - await page.reload() - - await expect(traceView).toBeVisible() - await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') - await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() - - const invalidSelectionUrl = await page.evaluate(() => { - const params = new URLSearchParams(location.hash.split('?')[1]) - params.set('attempt', 'constructor') - params.set('step', '999') - return `${location.origin}${location.pathname}${location.search}#/?${params}` - }) - await page.goto('about:blank') - await page.goto(invalidSelectionUrl) - - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:0', - hasTest: true, - step: '0', - }) - await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') - await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() - - await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: null, - hasTest: true, - step: null, - }) -} - -async function testPersistsAttemptInURL(page: Page) { - await openExplorerItem(page, 'retried test') - await page.getByTestId('trace-open-button').nth(1).click() - - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:1', - hasTest: true, - step: '0', - }) - - await page.reload() - - const traceView = page.getByTestId('trace-view') - await expect(traceView.frameLocator('iframe').getByText('retryCount: 1')).toBeVisible() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:1', - hasTest: true, - step: '0', - }) -} - -async function getTraceHashParams(page: Page) { - return page.evaluate(() => { - const params = new URLSearchParams(location.hash.split('?')[1]) - return { - attempt: params.get('attempt'), - hasTest: params.has('test'), - step: params.get('step'), - } - }) -} - async function testBasic(page: Page) { // selecting test case opens trace viewer const traceView = page.getByTestId('trace-view') @@ -431,3 +355,79 @@ async function testNested(page: Page) { 'test finished', ]) } + +async function testPersistsSelectionInURL(page: Page) { + await openExplorerItem(page, 'simple') + + const traceView = page.getByTestId('trace-view') + const traceSteps = traceView.getByTestId('trace-step') + await traceSteps.nth(1).click() + + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:0', + hasTest: true, + step: '1', + }) + + await page.reload() + + await expect(traceView).toBeVisible() + await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') + await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() + + const invalidSelectionUrl = await page.evaluate(() => { + const params = new URLSearchParams(location.hash.split('?')[1]) + params.set('attempt', 'constructor') + params.set('step', '999') + return `${location.origin}${location.pathname}${location.search}#/?${params}` + }) + await page.goto('about:blank') + await page.goto(invalidSelectionUrl) + + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:0', + hasTest: true, + step: '0', + }) + await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') + await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() + + await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: null, + hasTest: true, + step: null, + }) +} + +async function testPersistsAttemptInURL(page: Page) { + await openExplorerItem(page, 'retried test') + await page.getByTestId('trace-open-button').nth(1).click() + + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:1', + hasTest: true, + step: '0', + }) + + await page.reload() + + const traceView = page.getByTestId('trace-view') + await expect(traceView.frameLocator('iframe').getByText('retryCount: 1')).toBeVisible() + await expect.poll(() => getTraceHashParams(page)).toEqual({ + attempt: '0:1', + hasTest: true, + step: '0', + }) +} + +async function getTraceHashParams(page: Page) { + return page.evaluate(() => { + const params = new URLSearchParams(location.hash.split('?')[1]) + return { + attempt: params.get('attempt'), + hasTest: params.has('test'), + step: params.get('step'), + } + }) +} From 2a05d458eb103f9de627119676490d69a071118a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:14:15 +0900 Subject: [PATCH 05/42] refactor(ui): qualify trace URL params Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/navigation.ts | 2 +- packages/ui/client/composables/params.ts | 32 ++++++++++---------- test/ui/test/trace.spec.ts | 8 ++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/ui/client/composables/navigation.ts b/packages/ui/client/composables/navigation.ts index a0c91f0cb42d..9ea6405d9c38 100644 --- a/packages/ui/client/composables/navigation.ts +++ b/packages/ui/client/composables/navigation.ts @@ -112,7 +112,7 @@ export function showDashboard(show: boolean) { } } -export function navigateTo({ file, line, view, test, column }: Omit) { +export function navigateTo({ file, line, view, test, column }: Omit) { activeFileId.value = file lineNumber.value = line columnNumber.value = column diff --git a/packages/ui/client/composables/params.ts b/packages/ui/client/composables/params.ts index 6f281e3e1b53..f5593ec4accb 100644 --- a/packages/ui/client/composables/params.ts +++ b/packages/ui/client/composables/params.ts @@ -1,24 +1,24 @@ import { toRef, useUrlSearchParams } from '@vueuse/core' export interface Params { - attempt: null | string - file: string - view: null | 'graph' | 'editor' | 'console' - line: null | number - step: null | number - test: null | string - column: null | number + 'trace-attempt': null | string + 'file': string + 'view': null | 'graph' | 'editor' | 'console' + 'line': null | number + 'trace-step': null | number + 'test': null | string + 'column': null | number } const params = useUrlSearchParams('hash', { initialValue: { - attempt: null, - file: '', - view: null, - line: null, - step: null, - test: null, - column: null, + 'trace-attempt': null, + 'file': '', + 'view': null, + 'line': null, + 'trace-step': null, + 'test': null, + 'column': null, }, }) @@ -27,5 +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, 'attempt') -export const selectedTraceStep = toRef(params, 'step') +export const selectedTraceAttempt = toRef(params, 'trace-attempt') +export const selectedTraceStep = toRef(params, 'trace-step') diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 7d4edae96f5c..5f93781592cf 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -377,8 +377,8 @@ async function testPersistsSelectionInURL(page: Page) { const invalidSelectionUrl = await page.evaluate(() => { const params = new URLSearchParams(location.hash.split('?')[1]) - params.set('attempt', 'constructor') - params.set('step', '999') + params.set('trace-attempt', 'constructor') + params.set('trace-step', '999') return `${location.origin}${location.pathname}${location.search}#/?${params}` }) await page.goto('about:blank') @@ -425,9 +425,9 @@ async function getTraceHashParams(page: Page) { return page.evaluate(() => { const params = new URLSearchParams(location.hash.split('?')[1]) return { - attempt: params.get('attempt'), + attempt: params.get('trace-attempt'), hasTest: params.has('test'), - step: params.get('step'), + step: params.get('trace-step'), } }) } From 21d7faa3495bc78480c001e0bcf6735ab15b0a5b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:14:49 +0900 Subject: [PATCH 06/42] test(ui): simplify trace URL assertions Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 50 ++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 5f93781592cf..de7aa9043668 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -363,10 +363,10 @@ async function testPersistsSelectionInURL(page: Page) { const traceSteps = traceView.getByTestId('trace-step') await traceSteps.nth(1).click() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:0', - hasTest: true, - step: '1', + await expect.poll(() => getHashParams(page)).toMatchObject({ + 'trace-attempt': '0:0', + 'trace-step': '1', + 'test': expect.any(String), }) await page.reload() @@ -384,50 +384,46 @@ async function testPersistsSelectionInURL(page: Page) { await page.goto('about:blank') await page.goto(invalidSelectionUrl) - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:0', - hasTest: true, - step: '0', + await expect.poll(() => getHashParams(page)).toMatchObject({ + 'trace-attempt': '0:0', + 'trace-step': '0', + 'test': expect.any(String), }) await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: null, - hasTest: true, - step: null, - }) + await expect(traceView).not.toBeVisible() + const params = await getHashParams(page) + expect(params).toMatchObject({ test: expect.any(String) }) + expect(params).not.toHaveProperty('trace-attempt') + expect(params).not.toHaveProperty('trace-step') } async function testPersistsAttemptInURL(page: Page) { await openExplorerItem(page, 'retried test') await page.getByTestId('trace-open-button').nth(1).click() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:1', - hasTest: true, - step: '0', + await expect.poll(() => getHashParams(page)).toMatchObject({ + 'trace-attempt': '0:1', + 'trace-step': '0', + 'test': expect.any(String), }) await page.reload() const traceView = page.getByTestId('trace-view') await expect(traceView.frameLocator('iframe').getByText('retryCount: 1')).toBeVisible() - await expect.poll(() => getTraceHashParams(page)).toEqual({ - attempt: '0:1', - hasTest: true, - step: '0', + await expect.poll(() => getHashParams(page)).toMatchObject({ + 'trace-attempt': '0:1', + 'trace-step': '0', + 'test': expect.any(String), }) } -async function getTraceHashParams(page: Page) { +async function getHashParams(page: Page) { return page.evaluate(() => { const params = new URLSearchParams(location.hash.split('?')[1]) - return { - attempt: params.get('trace-attempt'), - hasTest: params.has('test'), - step: params.get('trace-step'), - } + return Object.fromEntries(params) }) } From 858cd855df87b04e5f09e3b33cbbebbe7bd45413 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:16:16 +0900 Subject: [PATCH 07/42] test(ui): parse trace params from page URL Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index de7aa9043668..740f86c63a65 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -421,9 +421,7 @@ async function testPersistsAttemptInURL(page: Page) { }) } -async function getHashParams(page: Page) { - return page.evaluate(() => { - const params = new URLSearchParams(location.hash.split('?')[1]) - return Object.fromEntries(params) - }) +function getHashParams(page: Page) { + const hash = new URL(page.url()).hash + return Object.fromEntries(new URLSearchParams(hash.split('?')[1])) } From 6c1a11b9088af64b5d9ca846c26b8bc636000e89 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:17:55 +0900 Subject: [PATCH 08/42] test(ui): explain trace URL scenarios Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 740f86c63a65..ef5ada4ebb22 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -359,6 +359,7 @@ async function testNested(page: Page) { async function testPersistsSelectionInURL(page: Page) { await openExplorerItem(page, 'simple') + // Selecting a trace step writes the complete trace selection to the URL. const traceView = page.getByTestId('trace-view') const traceSteps = traceView.getByTestId('trace-step') await traceSteps.nth(1).click() @@ -369,12 +370,14 @@ async function testPersistsSelectionInURL(page: Page) { 'test': expect.any(String), }) + // Reloading restores both the selected step and its rendered snapshot. await page.reload() await expect(traceView).toBeVisible() await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() + // Invalid attempt and step values fall back to the first available entry. const invalidSelectionUrl = await page.evaluate(() => { const params = new URLSearchParams(location.hash.split('?')[1]) params.set('trace-attempt', 'constructor') @@ -392,6 +395,7 @@ async function testPersistsSelectionInURL(page: Page) { await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() + // Closing removes only trace state and preserves the selected test. await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() await expect(traceView).not.toBeVisible() const params = await getHashParams(page) @@ -402,6 +406,8 @@ async function testPersistsSelectionInURL(page: Page) { async function testPersistsAttemptInURL(page: Page) { await openExplorerItem(page, 'retried test') + + // Opening a retry writes its attempt key to the URL. await page.getByTestId('trace-open-button').nth(1).click() await expect.poll(() => getHashParams(page)).toMatchObject({ @@ -410,6 +416,7 @@ async function testPersistsAttemptInURL(page: Page) { 'test': expect.any(String), }) + // Reloading restores the selected retry instead of the initial attempt. await page.reload() const traceView = page.getByTestId('trace-view') From 41e2e3d70f0a5a950199947480d86cbe354f1b35 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:19:36 +0900 Subject: [PATCH 09/42] nit --- test/ui/test/trace.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index ef5ada4ebb22..d249d762c240 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -398,7 +398,7 @@ async function testPersistsSelectionInURL(page: Page) { // Closing removes only trace state and preserves the selected test. await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() await expect(traceView).not.toBeVisible() - const params = await getHashParams(page) + const params = getHashParams(page) expect(params).toMatchObject({ test: expect.any(String) }) expect(params).not.toHaveProperty('trace-attempt') expect(params).not.toHaveProperty('trace-step') From 8ca9b637dfe64454405770b3fd996f9946282744 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:21:25 +0900 Subject: [PATCH 10/42] test(ui): retain exact trace test ID Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index d249d762c240..f83ce1de59d0 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -358,6 +358,8 @@ async function testNested(page: Page) { async function testPersistsSelectionInURL(page: Page) { await openExplorerItem(page, 'simple') + const testId = getHashParams(page).test + expect(testId).toBeDefined() // Selecting a trace step writes the complete trace selection to the URL. const traceView = page.getByTestId('trace-view') @@ -367,7 +369,7 @@ async function testPersistsSelectionInURL(page: Page) { await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:0', 'trace-step': '1', - 'test': expect.any(String), + 'test': testId, }) // Reloading restores both the selected step and its rendered snapshot. @@ -390,7 +392,7 @@ async function testPersistsSelectionInURL(page: Page) { await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:0', 'trace-step': '0', - 'test': expect.any(String), + 'test': testId, }) await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() @@ -399,13 +401,15 @@ async function testPersistsSelectionInURL(page: Page) { await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() await expect(traceView).not.toBeVisible() const params = getHashParams(page) - expect(params).toMatchObject({ test: expect.any(String) }) + expect(params).toMatchObject({ test: testId }) expect(params).not.toHaveProperty('trace-attempt') expect(params).not.toHaveProperty('trace-step') } async function testPersistsAttemptInURL(page: Page) { await openExplorerItem(page, 'retried test') + const testId = getHashParams(page).test + expect(testId).toBeDefined() // Opening a retry writes its attempt key to the URL. await page.getByTestId('trace-open-button').nth(1).click() @@ -413,7 +417,7 @@ async function testPersistsAttemptInURL(page: Page) { await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:1', 'trace-step': '0', - 'test': expect.any(String), + 'test': testId, }) // Reloading restores the selected retry instead of the initial attempt. @@ -424,7 +428,7 @@ async function testPersistsAttemptInURL(page: Page) { await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:1', 'trace-step': '0', - 'test': expect.any(String), + 'test': testId, }) } From 6b871f569a02dc4462d1801918197e4f00dd0ff6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:27:47 +0900 Subject: [PATCH 11/42] test(ui): contrast trace URL states Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index f83ce1de59d0..22d9f65ef04f 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -361,23 +361,37 @@ async function testPersistsSelectionInURL(page: Page) { const testId = getHashParams(page).test expect(testId).toBeDefined() - // Selecting a trace step writes the complete trace selection to the URL. const traceView = page.getByTestId('trace-view') const traceSteps = traceView.getByTestId('trace-step') + const traceFrame = traceView.frameLocator('iframe') + + // Opening a test selects its first trace step without explicit trace URL state. + await expect(traceView).toBeVisible() + await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') + await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() + const defaultParams = getHashParams(page) + expect(defaultParams).toMatchObject({ test: testId }) + expect(defaultParams).not.toHaveProperty('trace-attempt') + expect(defaultParams).not.toHaveProperty('trace-step') + + // Selecting another trace step updates the URL and rendered snapshot. await traceSteps.nth(1).click() - await expect.poll(() => getHashParams(page)).toMatchObject({ + const selectedParams = { 'trace-attempt': '0:0', 'trace-step': '1', 'test': testId, - }) + } + await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) + await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') + await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() - // Reloading restores both the selected step and its rendered snapshot. + // Reloading preserves the same URL, selected step, and rendered snapshot. await page.reload() - + await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) await expect(traceView).toBeVisible() await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') - await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Another' })).toBeVisible() + await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() // Invalid attempt and step values fall back to the first available entry. const invalidSelectionUrl = await page.evaluate(() => { @@ -388,14 +402,13 @@ async function testPersistsSelectionInURL(page: Page) { }) await page.goto('about:blank') await page.goto(invalidSelectionUrl) - await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:0', 'trace-step': '0', 'test': testId, }) await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') - await expect(traceView.frameLocator('iframe').getByRole('button', { name: 'Simple' })).toBeVisible() + await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() // Closing removes only trace state and preserves the selected test. await traceView.getByRole('button', { name: 'Close Trace Viewer' }).click() From 94554ada87a7d194761d241bf259ece118c1543b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:32:04 +0900 Subject: [PATCH 12/42] test(ui): construct invalid trace URL locally Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 22d9f65ef04f..0548f7bf61ec 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -394,14 +394,14 @@ async function testPersistsSelectionInURL(page: Page) { await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() // Invalid attempt and step values fall back to the first available entry. - const invalidSelectionUrl = await page.evaluate(() => { - const params = new URLSearchParams(location.hash.split('?')[1]) - params.set('trace-attempt', 'constructor') - params.set('trace-step', '999') - return `${location.origin}${location.pathname}${location.search}#/?${params}` - }) + const invalidSelectionUrl = new URL(page.url()) + const invalidParams = new URLSearchParams(invalidSelectionUrl.hash.split('?')[1]) + invalidParams.set('trace-attempt', 'constructor') + invalidParams.set('trace-step', '999') + invalidSelectionUrl.hash = `/?${invalidParams}` + // Leave the app so the invalid URL exercises initialization, not hash navigation. await page.goto('about:blank') - await page.goto(invalidSelectionUrl) + await page.goto(invalidSelectionUrl.href) await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:0', 'trace-step': '0', @@ -426,7 +426,6 @@ async function testPersistsAttemptInURL(page: Page) { // Opening a retry writes its attempt key to the URL. await page.getByTestId('trace-open-button').nth(1).click() - await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:1', 'trace-step': '0', @@ -435,9 +434,9 @@ async function testPersistsAttemptInURL(page: Page) { // Reloading restores the selected retry instead of the initial attempt. await page.reload() - const traceView = page.getByTestId('trace-view') - await expect(traceView.frameLocator('iframe').getByText('retryCount: 1')).toBeVisible() + const traceFrame = traceView.frameLocator('iframe') + await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:1', 'trace-step': '0', From fcdfaea67f70ef5469f63b7e7350b7228fa16274 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:33:34 +0900 Subject: [PATCH 13/42] test(ui): contrast trace attempt states Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 0548f7bf61ec..514228613579 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -424,24 +424,32 @@ async function testPersistsAttemptInURL(page: Page) { const testId = getHashParams(page).test expect(testId).toBeDefined() + const traceView = page.getByTestId('trace-view') + const traceFrame = traceView.frameLocator('iframe') + + // Opening a test selects its initial attempt without explicit trace URL state. + await expect(traceView).toBeVisible() + await expect(traceFrame.getByText('retryCount: 0')).toBeVisible() + const defaultParams = getHashParams(page) + expect(defaultParams).toMatchObject({ test: testId }) + expect(defaultParams).not.toHaveProperty('trace-attempt') + expect(defaultParams).not.toHaveProperty('trace-step') + // Opening a retry writes its attempt key to the URL. await page.getByTestId('trace-open-button').nth(1).click() - await expect.poll(() => getHashParams(page)).toMatchObject({ + const selectedParams = { 'trace-attempt': '0:1', 'trace-step': '0', 'test': testId, - }) + } + await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) + await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() - // Reloading restores the selected retry instead of the initial attempt. + // Reloading preserves the same URL and selected retry snapshot. await page.reload() - const traceView = page.getByTestId('trace-view') - const traceFrame = traceView.frameLocator('iframe') + await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) + await expect(traceView).toBeVisible() await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() - await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-attempt': '0:1', - 'trace-step': '0', - 'test': testId, - }) } function getHashParams(page: Page) { From 6a8f18f4fa569aabb67ef44ff14cd03b93232b29 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:36:56 +0900 Subject: [PATCH 14/42] test(ui): align trace persistence checks Co-authored-by: OpenCode (gpt-5.6-sol) --- test/ui/test/trace.spec.ts | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 514228613579..902a11308828 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -376,20 +376,21 @@ async function testPersistsSelectionInURL(page: Page) { // Selecting another trace step updates the URL and rendered snapshot. await traceSteps.nth(1).click() - - const selectedParams = { + await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:0', 'trace-step': '1', 'test': testId, - } - await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) + }) await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() // Reloading preserves the same URL, selected step, and rendered snapshot. await page.reload() - await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) - await expect(traceView).toBeVisible() + await expect.poll(() => getHashParams(page)).toMatchObject({ + 'trace-attempt': '0:0', + 'trace-step': '1', + 'test': testId, + }) await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() @@ -427,28 +428,22 @@ async function testPersistsAttemptInURL(page: Page) { const traceView = page.getByTestId('trace-view') const traceFrame = traceView.frameLocator('iframe') - // Opening a test selects its initial attempt without explicit trace URL state. - await expect(traceView).toBeVisible() - await expect(traceFrame.getByText('retryCount: 0')).toBeVisible() - const defaultParams = getHashParams(page) - expect(defaultParams).toMatchObject({ test: testId }) - expect(defaultParams).not.toHaveProperty('trace-attempt') - expect(defaultParams).not.toHaveProperty('trace-step') - // Opening a retry writes its attempt key to the URL. await page.getByTestId('trace-open-button').nth(1).click() - const selectedParams = { + await expect.poll(() => getHashParams(page)).toMatchObject({ 'trace-attempt': '0:1', 'trace-step': '0', 'test': testId, - } - await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) + }) await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() // Reloading preserves the same URL and selected retry snapshot. await page.reload() - await expect.poll(() => getHashParams(page)).toMatchObject(selectedParams) - await expect(traceView).toBeVisible() + await expect.poll(() => getHashParams(page)).toMatchObject({ + 'trace-attempt': '0:1', + 'trace-step': '0', + 'test': testId, + }) await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() } From ff911a8e874f1344f0330fb04d41000d69f15467 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:38:42 +0900 Subject: [PATCH 15/42] nit --- packages/ui/client/composables/params.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/client/composables/params.ts b/packages/ui/client/composables/params.ts index f5593ec4accb..11d66a25c144 100644 --- a/packages/ui/client/composables/params.ts +++ b/packages/ui/client/composables/params.ts @@ -1,24 +1,24 @@ import { toRef, useUrlSearchParams } from '@vueuse/core' export interface Params { - 'trace-attempt': null | string 'file': string 'view': null | 'graph' | 'editor' | 'console' 'line': null | number - 'trace-step': null | number 'test': null | string 'column': null | number + 'trace-attempt': null | string + 'trace-step': null | number } const params = useUrlSearchParams('hash', { initialValue: { - 'trace-attempt': null, 'file': '', 'view': null, 'line': null, - 'trace-step': null, 'test': null, 'column': null, + 'trace-attempt': null, + 'trace-step': null, }, }) From 4459f4cc46dfb688a35ffbbb5ce22cc029d2efa5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:40:09 +0900 Subject: [PATCH 16/42] nit --- packages/ui/client/composables/params.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ui/client/composables/params.ts b/packages/ui/client/composables/params.ts index 11d66a25c144..22e710503e7e 100644 --- a/packages/ui/client/composables/params.ts +++ b/packages/ui/client/composables/params.ts @@ -1,22 +1,22 @@ import { toRef, useUrlSearchParams } from '@vueuse/core' export interface Params { - 'file': string - 'view': null | 'graph' | 'editor' | 'console' - 'line': null | number - 'test': null | string - 'column': null | number + file: string + view: null | 'graph' | 'editor' | 'console' + line: null | number + test: null | string + column: null | number 'trace-attempt': null | string 'trace-step': null | number } const params = useUrlSearchParams('hash', { initialValue: { - 'file': '', - 'view': null, - 'line': null, - 'test': null, - 'column': null, + file: '', + view: null, + line: null, + test: null, + column: null, 'trace-attempt': null, 'trace-step': null, }, From c86e2bb6d367a4a40ed00e52b1aedf71300e2d1a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:48:30 +0900 Subject: [PATCH 17/42] chore(ui): fix URL param lint Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/params.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ui/client/composables/params.ts b/packages/ui/client/composables/params.ts index 22e710503e7e..11d66a25c144 100644 --- a/packages/ui/client/composables/params.ts +++ b/packages/ui/client/composables/params.ts @@ -1,22 +1,22 @@ import { toRef, useUrlSearchParams } from '@vueuse/core' export interface Params { - file: string - view: null | 'graph' | 'editor' | 'console' - line: null | number - test: null | string - column: null | number + 'file': string + 'view': null | 'graph' | 'editor' | 'console' + 'line': null | number + 'test': null | string + 'column': null | number 'trace-attempt': null | string 'trace-step': null | number } const params = useUrlSearchParams('hash', { initialValue: { - file: '', - view: null, - line: null, - test: null, - column: null, + 'file': '', + 'view': null, + 'line': null, + 'test': null, + 'column': null, 'trace-attempt': null, 'trace-step': null, }, From 0039e0c1a71bee2ff5e94324fd5c887a285ff323 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:52:24 +0900 Subject: [PATCH 18/42] refactor(ui): pass complete trace selection Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 36 +++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index 943cc4631459..7cb8f425d193 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -174,17 +174,17 @@ export function getTraceEntryClass(entry: BrowserTraceEntry) { export function openTrace(trace: BrowserTraceData, test: RunnerTestCase) { detailsPosition.value = 'bottom' - setActiveTrace(test, getTraceAttemptKey(trace), 0) + setActiveTrace({ + test, + attemptKey: getTraceAttemptKey(trace), + selectedStepIndex: 0, + }) } -function setActiveTrace(test: RunnerTestCase, attemptKey: string | undefined, stepIndex: number) { - activeTraceView.value = { - test, - attemptKey, - selectedStepIndex: stepIndex, - } - selectedTraceAttempt.value = attemptKey ?? null - selectedTraceStep.value = stepIndex +function setActiveTrace(selection: TraceSelection) { + activeTraceView.value = selection + selectedTraceAttempt.value = selection.attemptKey ?? null + selectedTraceStep.value = selection.selectedStepIndex } export function closeTrace() { @@ -233,7 +233,11 @@ export function initializeTraceView() { const selectedTrace = trace ?? Object.values(attempts)[0] const selectedStepIndex = parseTraceStep(step, selectedTrace?.entries.length ?? 0) detailsPosition.value = 'bottom' - setActiveTrace(test, selectedTrace ? getTraceAttemptKey(selectedTrace) : undefined, selectedStepIndex) + setActiveTrace({ + test, + attemptKey: selectedTrace ? getTraceAttemptKey(selectedTrace) : undefined, + selectedStepIndex, + }) return true } @@ -261,7 +265,11 @@ watch(selectedTest, (testId) => { if (test?.type === 'test' && isTraceViewEnabled(test.file)) { // Auto-open trace view when selecting a trace-enabled test. const trace = Object.values(getTraceAttemptMap(test.artifacts))[0] - setActiveTrace(test, trace ? getTraceAttemptKey(trace) : undefined, 0) + setActiveTrace({ + test, + attemptKey: trace ? getTraceAttemptKey(trace) : undefined, + selectedStepIndex: 0, + }) return } } @@ -280,7 +288,11 @@ watchEffect(() => { if (test?.type === 'test' && active.test !== test) { // Rerun produced a fresh test object; reset attempt selection. const trace = Object.values(getTraceAttemptMap(test.artifacts))[0] - setActiveTrace(test, trace ? getTraceAttemptKey(trace) : undefined, 0) + setActiveTrace({ + test, + attemptKey: trace ? getTraceAttemptKey(trace) : undefined, + selectedStepIndex: 0, + }) } } }) From ac5d8e69d6a85733633ad18fcabbe4258f1ed623 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:57:58 +0900 Subject: [PATCH 19/42] refactor(ui): keep default trace attempt implicit Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index 7cb8f425d193..f4ddab4c2b97 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -264,10 +264,8 @@ watch(selectedTest, (testId) => { const test = client.state.idMap.get(testId) if (test?.type === 'test' && isTraceViewEnabled(test.file)) { // Auto-open trace view when selecting a trace-enabled test. - const trace = Object.values(getTraceAttemptMap(test.artifacts))[0] setActiveTrace({ test, - attemptKey: trace ? getTraceAttemptKey(trace) : undefined, selectedStepIndex: 0, }) return @@ -287,10 +285,8 @@ watchEffect(() => { const test = client.state.idMap.get(testId) if (test?.type === 'test' && active.test !== test) { // Rerun produced a fresh test object; reset attempt selection. - const trace = Object.values(getTraceAttemptMap(test.artifacts))[0] setActiveTrace({ test, - attemptKey: trace ? getTraceAttemptKey(trace) : undefined, selectedStepIndex: 0, }) } From 4a78da97ad3919b214421664572a186834d45f43 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:59:20 +0900 Subject: [PATCH 20/42] refactor(ui): inline default trace selection Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index f4ddab4c2b97..ff29765bb62f 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -264,10 +264,7 @@ watch(selectedTest, (testId) => { const test = client.state.idMap.get(testId) if (test?.type === 'test' && isTraceViewEnabled(test.file)) { // Auto-open trace view when selecting a trace-enabled test. - setActiveTrace({ - test, - selectedStepIndex: 0, - }) + setActiveTrace({ test, selectedStepIndex: 0 }) return } } @@ -285,10 +282,7 @@ watchEffect(() => { const test = client.state.idMap.get(testId) if (test?.type === 'test' && active.test !== test) { // Rerun produced a fresh test object; reset attempt selection. - setActiveTrace({ - test, - selectedStepIndex: 0, - }) + setActiveTrace({ test, selectedStepIndex: 0 }) } } }) From bef9ded8cb9a7a57075e52af6f027de9f698097d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:00:02 +0900 Subject: [PATCH 21/42] fix(ui): preserve implicit trace attempt Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 6 +++--- test/ui/test/trace.spec.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index ff29765bb62f..fd4513ce59aa 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -229,13 +229,13 @@ export function initializeTraceView() { } const attempts = getTraceAttemptMap(test.artifacts) - const trace = attemptKey && Object.hasOwn(attempts, attemptKey) ? attempts[attemptKey] : undefined - const selectedTrace = trace ?? Object.values(attempts)[0] + const selectedAttemptKey = attemptKey && Object.hasOwn(attempts, attemptKey) ? attemptKey : undefined + const selectedTrace = selectedAttemptKey ? attempts[selectedAttemptKey] : Object.values(attempts)[0] const selectedStepIndex = parseTraceStep(step, selectedTrace?.entries.length ?? 0) detailsPosition.value = 'bottom' setActiveTrace({ test, - attemptKey: selectedTrace ? getTraceAttemptKey(selectedTrace) : undefined, + attemptKey: selectedAttemptKey, selectedStepIndex, }) return true diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 902a11308828..cf2b694d75af 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -377,20 +377,20 @@ async function testPersistsSelectionInURL(page: Page) { // Selecting another trace step updates the URL and rendered snapshot. await traceSteps.nth(1).click() await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-attempt': '0:0', 'trace-step': '1', 'test': testId, }) + expect(getHashParams(page)).not.toHaveProperty('trace-attempt') await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() // Reloading preserves the same URL, selected step, and rendered snapshot. await page.reload() await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-attempt': '0:0', 'trace-step': '1', 'test': testId, }) + expect(getHashParams(page)).not.toHaveProperty('trace-attempt') await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() @@ -404,10 +404,10 @@ async function testPersistsSelectionInURL(page: Page) { await page.goto('about:blank') await page.goto(invalidSelectionUrl.href) await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-attempt': '0:0', 'trace-step': '0', 'test': testId, }) + expect(getHashParams(page)).not.toHaveProperty('trace-attempt') await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() From b3695cb6e846a217cd6138501de0aa53679836a4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:22:10 +0900 Subject: [PATCH 22/42] refactor(ui): simplify config readiness check Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index fd4513ce59aa..06f1ea085121 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -218,7 +218,8 @@ export function initializeTraceView() { if (selectedTest.value !== testId) { return true } - if (!Object.hasOwn(config.value, 'root')) { + // Config starts empty and receives root once initialization completes. + if (!config.value.root) { return false } From fcfbbce73b5be2e4ff3a66c50bb8fd3bc8802c01 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:27:17 +0900 Subject: [PATCH 23/42] refactor(ui): use map for trace attempts Co-authored-by: OpenCode (gpt-5.6-sol) --- .../client/components/trace/TraceArtifacts.vue | 2 +- packages/ui/client/composables/trace-view.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/client/components/trace/TraceArtifacts.vue b/packages/ui/client/components/trace/TraceArtifacts.vue index 3cd3b0147386..38dd0adb492c 100644 --- a/packages/ui/client/components/trace/TraceArtifacts.vue +++ b/packages/ui/client/components/trace/TraceArtifacts.vue @@ -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), })) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index 06f1ea085121..e1bbb758d7a6 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -82,7 +82,7 @@ function normalizeTraceEntries(entries: BrowserTraceEntry[]): NormalizedBrowserT return merged } -export function getTraceAttemptMap(artifacts: TestArtifact[]): Record { +export function getTraceAttemptMap(artifacts: TestArtifact[]): Map { const grouped: Record = {} for (const artifact of artifacts) { if (artifact.type !== 'internal:browserTrace') { @@ -94,14 +94,14 @@ export function getTraceAttemptMap(artifacts: TestArtifact[]): Record = {} + const merged = new Map() 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 } @@ -109,8 +109,8 @@ export function getTraceAttemptMap(artifacts: TestArtifact[]): Record Date: Fri, 21 Aug 2026 12:45:11 +0900 Subject: [PATCH 24/42] refactor(ui): derive selected trace test Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 52 ++++++++------------ 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index e1bbb758d7a6..2b5bcf159071 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -1,6 +1,6 @@ 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' @@ -201,34 +201,30 @@ export function selectActiveTraceStep(index: number) { } } +// 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 +}) + +// Restore trace URL state once its selected test becomes available. export function initializeTraceView() { const attemptKey = selectedTraceAttempt.value const step = selectedTraceStep.value - if (attemptKey == null && step == null) { - return - } - - const testId = selectedTest.value - if (!testId) { - closeTrace() + if (!selectedTest.value || (attemptKey == null && step == null)) { return } const restoreTrace = () => { - if (selectedTest.value !== testId) { - return true - } - // Config starts empty and receives root once initialization completes. - if (!config.value.root) { + const test = selectedTestTask.value + if (!test) { return false } - const test = client.state.idMap.get(testId) - if (test?.type !== 'test' || !isTraceViewEnabled(test.file)) { - closeTrace() - return true - } - const attempts = getTraceAttemptMap(test.artifacts) const selectedAttemptKey = attemptKey != null && attempts.has(attemptKey) ? attemptKey : undefined const selectedTrace = selectedAttemptKey ? attempts.get(selectedAttemptKey) : [...attempts.values()][0] @@ -242,15 +238,9 @@ export function initializeTraceView() { return true } - if (restoreTrace()) { - return + if (!restoreTrace()) { + watch(selectedTestTask, restoreTrace, { once: true }) } - - const stop = watch([() => client.state.idMap.get(testId), config, selectedTest], () => { - if (restoreTrace()) { - stop() - } - }) } function parseTraceStep(value: unknown, entryCount: number): number { @@ -262,8 +252,8 @@ function parseTraceStep(value: unknown, entryCount: number): number { // 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. setActiveTrace({ test, selectedStepIndex: 0 }) return @@ -280,8 +270,8 @@ 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. setActiveTrace({ test, selectedStepIndex: 0 }) } From 136a924496c97205a155ab1b7252935152e84f3c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:03:09 +0900 Subject: [PATCH 25/42] refactor(ui): camelcase trace URL params Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/navigation.ts | 2 +- packages/ui/client/composables/params.ts | 32 +++++++-------- test/ui/test/trace.spec.ts | 42 ++++++++++---------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/ui/client/composables/navigation.ts b/packages/ui/client/composables/navigation.ts index 9ea6405d9c38..4e180613ada0 100644 --- a/packages/ui/client/composables/navigation.ts +++ b/packages/ui/client/composables/navigation.ts @@ -112,7 +112,7 @@ export function showDashboard(show: boolean) { } } -export function navigateTo({ file, line, view, test, column }: Omit) { +export function navigateTo({ file, line, view, test, column }: Omit) { activeFileId.value = file lineNumber.value = line columnNumber.value = column diff --git a/packages/ui/client/composables/params.ts b/packages/ui/client/composables/params.ts index 11d66a25c144..f779704e6611 100644 --- a/packages/ui/client/composables/params.ts +++ b/packages/ui/client/composables/params.ts @@ -1,24 +1,24 @@ import { toRef, useUrlSearchParams } from '@vueuse/core' export interface Params { - 'file': string - 'view': null | 'graph' | 'editor' | 'console' - 'line': null | number - 'test': null | string - 'column': null | number - 'trace-attempt': null | string - 'trace-step': null | number + file: string + view: null | 'graph' | 'editor' | 'console' + line: null | number + test: null | string + column: null | number + traceAttempt: null | string + traceStep: null | number } const params = useUrlSearchParams('hash', { initialValue: { - 'file': '', - 'view': null, - 'line': null, - 'test': null, - 'column': null, - 'trace-attempt': null, - 'trace-step': null, + file: '', + view: null, + line: null, + test: null, + column: null, + traceAttempt: null, + traceStep: null, }, }) @@ -27,5 +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, 'trace-attempt') -export const selectedTraceStep = toRef(params, 'trace-step') +export const selectedTraceAttempt = toRef(params, 'traceAttempt') +export const selectedTraceStep = toRef(params, 'traceStep') diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index cf2b694d75af..e229ad4a7f06 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -371,43 +371,43 @@ async function testPersistsSelectionInURL(page: Page) { await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() const defaultParams = getHashParams(page) expect(defaultParams).toMatchObject({ test: testId }) - expect(defaultParams).not.toHaveProperty('trace-attempt') - expect(defaultParams).not.toHaveProperty('trace-step') + expect(defaultParams).not.toHaveProperty('traceAttempt') + expect(defaultParams).not.toHaveProperty('traceStep') // Selecting another trace step updates the URL and rendered snapshot. await traceSteps.nth(1).click() await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-step': '1', - 'test': testId, + traceStep: '1', + test: testId, }) - expect(getHashParams(page)).not.toHaveProperty('trace-attempt') + expect(getHashParams(page)).not.toHaveProperty('traceAttempt') await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() // Reloading preserves the same URL, selected step, and rendered snapshot. await page.reload() await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-step': '1', - 'test': testId, + traceStep: '1', + test: testId, }) - expect(getHashParams(page)).not.toHaveProperty('trace-attempt') + expect(getHashParams(page)).not.toHaveProperty('traceAttempt') await expect(traceSteps.nth(1)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Another' })).toBeVisible() // Invalid attempt and step values fall back to the first available entry. const invalidSelectionUrl = new URL(page.url()) const invalidParams = new URLSearchParams(invalidSelectionUrl.hash.split('?')[1]) - invalidParams.set('trace-attempt', 'constructor') - invalidParams.set('trace-step', '999') + invalidParams.set('traceAttempt', 'constructor') + invalidParams.set('traceStep', '999') invalidSelectionUrl.hash = `/?${invalidParams}` // Leave the app so the invalid URL exercises initialization, not hash navigation. await page.goto('about:blank') await page.goto(invalidSelectionUrl.href) await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-step': '0', - 'test': testId, + traceStep: '0', + test: testId, }) - expect(getHashParams(page)).not.toHaveProperty('trace-attempt') + expect(getHashParams(page)).not.toHaveProperty('traceAttempt') await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() @@ -416,8 +416,8 @@ async function testPersistsSelectionInURL(page: Page) { await expect(traceView).not.toBeVisible() const params = getHashParams(page) expect(params).toMatchObject({ test: testId }) - expect(params).not.toHaveProperty('trace-attempt') - expect(params).not.toHaveProperty('trace-step') + expect(params).not.toHaveProperty('traceAttempt') + expect(params).not.toHaveProperty('traceStep') } async function testPersistsAttemptInURL(page: Page) { @@ -431,18 +431,18 @@ async function testPersistsAttemptInURL(page: Page) { // Opening a retry writes its attempt key to the URL. await page.getByTestId('trace-open-button').nth(1).click() await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-attempt': '0:1', - 'trace-step': '0', - 'test': testId, + traceAttempt: '0:1', + traceStep: '0', + test: testId, }) await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() // Reloading preserves the same URL and selected retry snapshot. await page.reload() await expect.poll(() => getHashParams(page)).toMatchObject({ - 'trace-attempt': '0:1', - 'trace-step': '0', - 'test': testId, + traceAttempt: '0:1', + traceStep: '0', + test: testId, }) await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() } From 42a1fea5940e63ca8abd0f4196a07d353ef84427 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:58:04 +0900 Subject: [PATCH 26/42] fix(ui): close rebuilt trace document Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/components/trace/TraceView.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/client/components/trace/TraceView.vue b/packages/ui/client/components/trace/TraceView.vue index 744debad28dc..11c2f0d4074e 100644 --- a/packages/ui/client/components/trace/TraceView.vue +++ b/packages/ui/client/components/trace/TraceView.vue @@ -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() @@ -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 From b89ac56f2b29d0360d9c03fa628f81025b2a7864 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:59:26 +0900 Subject: [PATCH 27/42] nit --- packages/ui/client/composables/trace-view.ts | 74 ++++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index 2b5bcf159071..21d4f5f50f4b 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -211,43 +211,6 @@ const selectedTestTask = computed(() => { : undefined }) -// Restore trace URL state once its selected test becomes available. -export 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 -} - // 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) => { @@ -297,3 +260,40 @@ export function getTraceAttemptLabel(trace: BrowserTraceData) { } return parts.join(' / ') } + +// Restore trace URL state once its selected test becomes available. +export 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 +} From bd2f2217bb2f6218b04d8ef18023ff0dde54a21c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:01:35 +0900 Subject: [PATCH 28/42] refactor(ui): own trace view initialization Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/trace-view.ts | 4 +++- packages/ui/client/pages/index.vue | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index 21d4f5f50f4b..5e4c78170a2d 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -262,7 +262,9 @@ export function getTraceAttemptLabel(trace: BrowserTraceData) { } // Restore trace URL state once its selected test becomes available. -export function initializeTraceView() { +initializeTraceView() + +function initializeTraceView() { const attemptKey = selectedTraceAttempt.value const step = selectedTraceStep.value if (!selectedTest.value || (attemptKey == null && step == null)) { diff --git a/packages/ui/client/pages/index.vue b/packages/ui/client/pages/index.vue index 931470ff6602..d0b0f0ea9f70 100644 --- a/packages/ui/client/pages/index.vue +++ b/packages/ui/client/pages/index.vue @@ -20,10 +20,9 @@ import { mainSizes, panels, } from '~/composables/navigation' -import { activeTraceView, initializeTraceView } from '~/composables/trace-view' +import { activeTraceView } from '~/composables/trace-view' const dashboardVisible = initializeNavigation() -initializeTraceView() const onBrowserPanelResizing = useDebounceFn(({ panes }: { panes: { size: number }[] }) => { // don't trigger events in the iframe while resizing From 045da8dc05ec76229051aebd8d3b95132334ccab Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:56:17 +0900 Subject: [PATCH 29/42] feat(ui): persist trace pane sizes Co-authored-by: OpenCode (gpt-5.6-sol) --- packages/ui/client/composables/navigation.ts | 4 ++++ packages/ui/client/pages/index.vue | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/ui/client/composables/navigation.ts b/packages/ui/client/composables/navigation.ts index 4e180613ada0..fd0468c6dea3 100644 --- a/packages/ui/client/composables/navigation.ts +++ b/packages/ui/client/composables/navigation.ts @@ -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( 'vitest-ui_details-panel-visible', diff --git a/packages/ui/client/pages/index.vue b/packages/ui/client/pages/index.vue index d0b0f0ea9f70..1527066ce6d1 100644 --- a/packages/ui/client/pages/index.vue +++ b/packages/ui/client/pages/index.vue @@ -19,6 +19,7 @@ import { initializeNavigation, mainSizes, panels, + traceSizes, } from '~/composables/navigation' import { activeTraceView } from '~/composables/trace-view' @@ -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() @@ -119,11 +129,13 @@ function allowBrowserEvents() { v-if="browserState.config.browser?.traceView.enabled" class="h-full" :horizontal="detailsPosition === 'right'" + @resize="preventBrowserEvents" + @resized="onTraceResized" > - + - + From b2be1b2957d3621bcae54629c15d82038fc74b01 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:50:56 +0900 Subject: [PATCH 30/42] fix(ui): persist trace replay pane sizes Co-authored-by: OpenCode (gpt-5.6-sol) --- .../ui/client/components/trace/TraceView.vue | 12 +++- packages/ui/client/composables/navigation.ts | 4 +- packages/ui/client/pages/index.vue | 16 +---- test/ui/test/trace.spec.ts | 66 ++++++++++++++++++- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/packages/ui/client/components/trace/TraceView.vue b/packages/ui/client/components/trace/TraceView.vue index bb5767637188..d085929487b9 100644 --- a/packages/ui/client/components/trace/TraceView.vue +++ b/packages/ui/client/components/trace/TraceView.vue @@ -4,6 +4,7 @@ import { createCache, createMirror, rebuild } from 'rrweb-snapshot' import { Pane, Splitpanes } from 'splitpanes' import { computed, ref, watch } from 'vue' import { openLocation } from '~/composables/location' +import { traceSizes } from '~/composables/navigation' import { getTraceEntryClass, selectActiveTraceStep } from '~/composables/trace-view' const props = defineProps<{ @@ -172,13 +173,20 @@ function formatStepName(step: NormalizedBrowserTraceEntry) { function isTraceStepInProgress(step: NormalizedBrowserTraceEntry) { return step.range?.phase === 'start' } + +function onTraceResized({ panes }: { panes: { size: number }[] }) { + if (panes.length === 2) { + traceSizes.value = [panes[0].size, panes[1].size] + } +}