diff --git a/packages/ui/client/components/trace/TraceArtifacts.vue b/packages/ui/client/components/trace/TraceArtifacts.vue index 3cd3b0147..38dd0adb4 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/components/trace/TraceView.vue b/packages/ui/client/components/trace/TraceView.vue index 303adbbd8..bb5767637 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 HTMLElement | null diff --git a/packages/ui/client/composables/navigation.ts b/packages/ui/client/composables/navigation.ts index 395583c9e..4e180613a 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 2d1e0351b..f779704e6 100644 --- a/packages/ui/client/composables/params.ts +++ b/packages/ui/client/composables/params.ts @@ -6,6 +6,8 @@ export interface Params { line: null | number test: null | string column: null | number + traceAttempt: null | string + traceStep: null | number } const params = useUrlSearchParams('hash', { @@ -15,6 +17,8 @@ const params = useUrlSearchParams('hash', { line: null, test: null, column: null, + traceAttempt: null, + traceStep: 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, 'traceAttempt') +export const selectedTraceStep = toRef(params, 'traceStep') diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index c6dfaec9b..b189ce123 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -1,10 +1,10 @@ import type { RunnerTestCase, RunnerTestFile, TestArtifact } from 'vitest' import type { BrowserTraceData, BrowserTraceEntry } from '../../../browser/src/client/tester/trace' -import { ref, watch, watchEffect } from 'vue' +import { computed, ref, watch, watchEffect } from 'vue' import { getProjectConfigByName } from '~/utils/task' import { browserState, client, config } from './client' import { detailsPosition } from './navigation' -import { selectedTest } from './params' +import { selectedTest, selectedTraceAttempt, selectedTraceStep } from './params' export interface TraceSelection { test: RunnerTestCase @@ -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 { + const test = selectedTest.value + ? client.state.idMap.get(selectedTest.value) + : undefined + return test?.type === 'test' && isTraceViewEnabled(test.file) + ? test + : undefined +}) + // Open/close only on selected-test navigation so the close button can clear the // trace view without being auto-opened again for the same selected test. +// Flush synchronously so traceStep is set before the URL is updated. +// Vueuse URL watcher pauses while writing and would otherwise miss the change. watch(selectedTest, (testId) => { if (testId) { - const test = client.state.idMap.get(testId) - if (test?.type === 'test' && isTraceViewEnabled(test.file)) { + const test = selectedTestTask.value + if (test) { // Auto-open trace view when selecting a trace-enabled test. - activeTraceView.value = { test, selectedStepIndex: 0 } + setActiveTrace({ test, selectedStepIndex: 0 }) return } } // Close trace view when navigation moves away from a trace-enabled test. closeTrace() -}) +}, { flush: 'sync' }) // Keep the pane attached to the latest test object after reruns, and reset the // attempt selection because retries/repeats belong to one run. @@ -214,10 +235,10 @@ watchEffect(() => { const active = activeTraceView.value const testId = selectedTest.value if (active && testId && active.test.id === testId) { - const test = client.state.idMap.get(testId) - if (test?.type === 'test' && active.test !== test) { + const test = selectedTestTask.value + if (test && active.test !== test) { // Rerun produced a fresh test object; reset attempt selection. - activeTraceView.value = { test, selectedStepIndex: 0 } + setActiveTrace({ test, selectedStepIndex: 0 }) } } }) @@ -241,3 +262,42 @@ export function getTraceAttemptLabel(trace: BrowserTraceData) { } return parts.join(' / ') } + +// Restore trace URL state once its selected test becomes available. +initializeTraceView() + +function initializeTraceView() { + const attemptKey = selectedTraceAttempt.value + const step = selectedTraceStep.value + if (!selectedTest.value || (attemptKey == null && step == null)) { + return + } + + const restoreTrace = () => { + const test = selectedTestTask.value + if (!test) { + return false + } + + const attempts = getTraceAttemptMap(test.artifacts) + const selectedAttemptKey = attemptKey != null && attempts.has(attemptKey) ? attemptKey : undefined + const selectedTrace = selectedAttemptKey ? attempts.get(selectedAttemptKey) : [...attempts.values()][0] + const selectedStepIndex = parseTraceStep(step, selectedTrace?.entries.length ?? 0) + detailsPosition.value = 'bottom' + setActiveTrace({ + test, + attemptKey: selectedAttemptKey, + selectedStepIndex, + }) + return true + } + + if (!restoreTrace()) { + watch(selectedTestTask, restoreTrace, { once: true }) + } +} + +function parseTraceStep(value: unknown, entryCount: number): number { + const step = typeof value === 'number' ? value : Number(value) + return Number.isInteger(step) && step >= 0 && step < entryCount ? step : 0 +} diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 0d910c6ff..0c18bc4c0 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -64,6 +64,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', () => { @@ -138,6 +146,14 @@ test.describe('html reporter', () => { 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) + }) }) async function testBasic(page: Page) { @@ -363,3 +379,109 @@ async function testNested(page: Page) { 'test finished', ]) } + +async function testPersistsSelectionInURL(page: Page) { + await openExplorerItem(page, 'simple') + const testId = getHashParams(page).test + expect(testId).toBeDefined() + + 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 and persists it in the URL. + await expect(traceView).toBeVisible() + await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') + await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() + await expect.poll(() => getHashParams(page)).toMatchObject({ + traceStep: '0', + test: testId, + }) + expect(getHashParams(page)).not.toHaveProperty('traceAttempt') + + // Reloading restores the auto-opened default step. + await page.reload() + await expect.poll(() => getHashParams(page)).toMatchObject({ + traceStep: '0', + test: testId, + }) + await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') + await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() + + // Selecting another trace step updates the URL and rendered snapshot. + await traceSteps.nth(1).click() + await expect.poll(() => getHashParams(page)).toMatchObject({ + traceStep: '1', + test: testId, + }) + 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({ + traceStep: '1', + test: testId, + }) + 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('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({ + traceStep: '0', + test: testId, + }) + expect(getHashParams(page)).not.toHaveProperty('traceAttempt') + await expect(traceSteps.nth(0)).toHaveAttribute('aria-selected', 'true') + 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() + await expect(traceView).not.toBeVisible() + const params = getHashParams(page) + expect(params).toMatchObject({ test: testId }) + expect(params).not.toHaveProperty('traceAttempt') + expect(params).not.toHaveProperty('traceStep') +} + +async function testPersistsAttemptInURL(page: Page) { + await openExplorerItem(page, 'retried test') + const testId = getHashParams(page).test + expect(testId).toBeDefined() + + const traceView = page.getByTestId('trace-view') + const traceFrame = traceView.frameLocator('iframe') + + // 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({ + 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({ + traceAttempt: '0:1', + traceStep: '0', + test: testId, + }) + await expect(traceFrame.getByText('retryCount: 1')).toBeVisible() +} + +function getHashParams(page: Page) { + const hash = new URL(page.url()).hash + return Object.fromEntries(new URLSearchParams(hash.split('?')[1])) +}