Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5bff4fb
test(ui): add custom trace fixture
hi-ogawa Aug 24, 2026
02f33fc
test(ui): cover custom trace artifacts
hi-ogawa Aug 24, 2026
2217c31
test(ui): cover custom trace html report
hi-ogawa Aug 24, 2026
46b7a58
feat(ui): open trace artifacts automatically
hi-ogawa Aug 24, 2026
d696adc
fix(stack): filter helper location frames
hi-ogawa Aug 24, 2026
3210d6f
test(artifacts): cover helper callsite
hi-ogawa Aug 24, 2026
2bbc17f
feat(ui): support custom trace locations
hi-ogawa Aug 24, 2026
0b54fbb
Merge branch 'main' into trace-custom
hi-ogawa Aug 25, 2026
cc2e4f8
refactor(ui): extract custom trace helper
hi-ogawa Aug 25, 2026
87db0c4
refactor(ui): refine custom trace recorder
hi-ogawa Aug 25, 2026
17abfda
test(ui): document trace attempt metadata
hi-ogawa Aug 25, 2026
87648f7
nit
hi-ogawa Aug 25, 2026
d373cde
test(ui): prototype custom trace runner
hi-ogawa Aug 25, 2026
de4529f
test(ui): prototype custom trace marks
hi-ogawa Aug 25, 2026
594c5f1
test(ui): prototype trace lifecycle entry
hi-ogawa Aug 25, 2026
0e125e2
refactor(ui): make trace finish explicit
hi-ogawa Aug 25, 2026
cc49e99
test(ui): resolve custom trace stacks
hi-ogawa Aug 25, 2026
f18f120
test(ui): prototype automatic trace fixture
hi-ogawa Aug 25, 2026
ddad2fb
refactor(ui): omit resolved trace stack
hi-ogawa Aug 25, 2026
41b7c09
nit
hi-ogawa Aug 25, 2026
663396e
perf(ui): load custom trace snapshot lazily
hi-ogawa Aug 25, 2026
398d18e
test(ui): prototype traced playwright actions
hi-ogawa Aug 25, 2026
259540f
refactor(ui): extend custom trace expect
hi-ogawa Aug 25, 2026
fc27bad
test(ui): serve custom trace demo app
hi-ogawa Aug 25, 2026
16982b0
test(ui): configure trace demo base url
hi-ogawa Aug 25, 2026
d7f7df7
test(ui): serve trace demo with vite
hi-ogawa Aug 25, 2026
17397ef
Merge branch 'trace-custom' into trace-playwright-instrumentation
hi-ogawa Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/ui/client/components/views/ViewEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function codemirrorChanges() {
}

const TRACE_GUTTER_ID = 'trace-step-gutter'
const traceGutterConfigs = isTraceViewEnabled(props.file)
const traceGutterConfigs = activeTraceView.value || isTraceViewEnabled(props.file)
? [{ className: TRACE_GUTTER_ID, style: 'width: 14px' }]
: []
let traceGutterLines: number[] = []
Expand Down
13 changes: 11 additions & 2 deletions packages/ui/client/composables/trace-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ export function getTraceAttemptMap(artifacts: TestArtifact[]): Map<string, Norma
const trace = artifact.data as BrowserTraceData
const key = getTraceAttemptKey(trace)
grouped[key] ??= []
grouped[key].push(trace)
grouped[key].push({
...trace,
entries: trace.entries.map(entry => ({
...entry,
location: entry.location ?? artifact.location,
})),
})
}

const merged = new Map<string, NormalizedBrowserTraceData>()
Expand Down Expand Up @@ -212,7 +218,9 @@ const selectedTestTask = computed(() => {
const test = selectedTest.value
? client.state.idMap.get(selectedTest.value)
: undefined
return test?.type === 'test' && isTraceViewEnabled(test.file)
const hasTrace = test?.type === 'test'
&& test.artifacts.some(artifact => artifact.type === 'internal:browserTrace')
return test?.type === 'test' && (isTraceViewEnabled(test.file) || hasTrace)
? test
: undefined
})
Expand All @@ -226,6 +234,7 @@ watch(selectedTest, (testId) => {
const test = selectedTestTask.value
if (test) {
// Auto-open trace view when selecting a trace-enabled test.
detailsPosition.value = 'bottom'
setActiveTrace({ test, selectedStepIndex: 0 })
return
}
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions test/ui/fixtures/trace-custom/app/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const button = document.querySelector('button')
const input = document.querySelector('input')
const output = document.querySelector('output')

button.addEventListener('click', () => {
button.textContent = 'After action'
})

input.addEventListener('input', () => {
output.textContent = `Attempt ${input.value}`
})
19 changes: 19 additions & 0 deletions test/ui/fixtures/trace-custom/app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trace demo</title>
</head>
<body>
<main>
<button type="button">Before action</button>
<label>
Attempt
<input type="number" value="0">
</label>
<output>Attempt 0</output>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
15 changes: 15 additions & 0 deletions test/ui/fixtures/trace-custom/attempts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test } from './trace/test'

let attemptIndex = 0

test('custom trace attempts', { retry: 1, repeats: 1 }, async ({ page, trace }) => {
const currentAttempt = attemptIndex++

await page.goto('/')
await page.getByLabel('Attempt').fill(String(currentAttempt))
await trace.snapshot('attempt')

if (currentAttempt % 2 === 0) {
throw new Error('Retry this attempt')
}
})
10 changes: 10 additions & 0 deletions test/ui/fixtures/trace-custom/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { expect } from 'vitest'
import { test } from './trace/test'

test('custom trace', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: 'Before action' }).click()

await expect(page.getByRole('button', { name: 'After action' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Missing' }).click({ timeout: 10 })).rejects.toThrow()
})
29 changes: 29 additions & 0 deletions test/ui/fixtures/trace-custom/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { AddressInfo } from 'node:net'
import type { TestProject } from 'vitest/node'
import { fileURLToPath } from 'node:url'
import { preview } from 'vite'

declare module 'vitest' {
interface ProvidedContext {
baseURL: string
}
}

export async function setup({ provide }: TestProject): Promise<() => Promise<void>> {
const root = fileURLToPath(new URL('./app', import.meta.url))
const server = await preview({
root,
logLevel: 'silent' as const,
build: {
outDir: root,
},
preview: {
host: '127.0.0.1',
port: 0,
},
})

const address = server.httpServer.address() as AddressInfo
provide('baseURL', `http://127.0.0.1:${address.port}`)
return () => server.close()
}
20 changes: 20 additions & 0 deletions test/ui/fixtures/trace-custom/trace/active.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { TraceRecorder } from './recorder'

let activeTraceRecorder: TraceRecorder | undefined

export function setActiveTraceRecorder(recorder: TraceRecorder): void {
activeTraceRecorder = recorder
}

export function clearActiveTraceRecorder(recorder: TraceRecorder): void {
if (activeTraceRecorder === recorder) {
activeTraceRecorder = undefined
}
}

export function getActiveTraceRecorder(): TraceRecorder {
if (!activeTraceRecorder) {
throw new Error('Trace expect was used outside of a traced test')
}
return activeTraceRecorder
}
27 changes: 27 additions & 0 deletions test/ui/fixtures/trace-custom/trace/attempt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { RunnerTestCase, TestContext, TestTryOptions } from 'vitest'
import type { TraceAttempt } from './recorder'

const traceAttemptKey = Symbol.for('vitest.traceAttempt')
type TraceContext = TestContext & { [traceAttemptKey]?: TraceAttempt }

export function startTraceAttempt(task: RunnerTestCase, options: TestTryOptions): void {
const context = task.context as TraceContext
context[traceAttemptKey] = {
...options,
startTime: performance.now(),
}
}

export function getTraceAttempt(task: TestContext['task']): TraceAttempt {
const context = task.context as TraceContext
const attempt = context[traceAttemptKey]
if (!attempt) {
throw new Error('Trace attempt was not initialized by the custom runner')
}
return attempt
}

export function finishTraceAttempt(task: RunnerTestCase): void {
const context = task.context as TraceContext
delete context[traceAttemptKey]
}
48 changes: 48 additions & 0 deletions test/ui/fixtures/trace-custom/trace/expect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Locator } from 'playwright'
// @ts-ignore
import { parseStacktrace } from '@vitest/utils/source-map'
import { expect } from 'vitest'
import { getActiveTraceRecorder } from './active'

expect.extend({
async toBeVisible(actual: unknown, options?: { timeout?: number }) {
if (!isLocator(actual)) {
throw new TypeError('toBeVisible expects a Playwright Locator')
}

const frame = parseStacktrace(new Error().stack ?? '').find(({ file }) => {
return !file.includes('/node_modules/') && !file.includes('/trace/')
})
const location = frame
? { file: frame.file, line: frame.line, column: frame.column }
: undefined
const isNot = this.isNot
try {
await getActiveTraceRecorder().assert(
`expect.${isNot ? 'not.' : ''}toBeVisible`,
() => actual.waitFor({
state: isNot ? 'hidden' : 'visible',
timeout: options?.timeout,
}),
{ location },
)
return {
pass: !isNot,
message: () => `Expected locator ${isNot ? '' : 'not '}to be visible`,
}
}
catch (error) {
return {
pass: isNot,
message: () => error instanceof Error ? error.message : String(error),
}
}
},
})

function isLocator(value: unknown): value is Locator {
return !!value
&& typeof value === 'object'
&& typeof (value as Locator).isVisible === 'function'
&& typeof (value as Locator).locator === 'function'
}
Loading
Loading